diff --git a/src/app/(private)/data-sources/new/fields/PayloadCMSFields.tsx b/src/app/(private)/data-sources/new/fields/PayloadCMSFields.tsx new file mode 100644 index 00000000..3249c05e --- /dev/null +++ b/src/app/(private)/data-sources/new/fields/PayloadCMSFields.tsx @@ -0,0 +1,129 @@ +import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; +import { DataSourceType } from "@/server/models/DataSource"; +import { Badge } from "@/shadcn/ui/badge"; +import { Input } from "@/shadcn/ui/input"; +import type { PayloadCMSConfig } from "@/server/models/DataSource"; + +export default function PayloadCMSFields({ + config, + onChange, +}: { + config: Partial; + onChange: ( + config: Partial< + Pick + >, + ) => void; +}) { + if (config.type !== DataSourceType.PayloadCMS) return; + + return ( + <> + + onChange({ apiBaseUrl: e.target.value })} + /> + + + onChange({ collectionName: e.target.value })} + /> + + + onChange({ apiKey: e.target.value })} + /> + + + ); +} + +const ApiBaseUrlHelpText = ( + <> +

API Base URL

+

+ This is the base URL of your PayloadCMS instance. It should include the + protocol (https://) but not the /api{" "} + path. +

+

Example: https://your-site.com

+ +); + +const CollectionNameHelpText = ( + <> +

Collection Name

+

+ The collection slug as defined in your PayloadCMS configuration. This is + the name used in the API endpoints. +

+

+ For example, if your collection is accessible at{" "} + /api/posts, the collection name is{" "} + posts. +

+ +); + +const ApiKeyHelpText = ( + <> +

How to generate an API Key in PayloadCMS

+
    +
  1. Log in to your PayloadCMS admin panel.
  2. +
  3. + Navigate to the Users collection (or wherever API keys are configured + in your instance). +
  4. +
  5. + Create a new API key with permissions to read and write data in your + collection. +
  6. +
  7. Copy the API key and paste it here.
  8. +
+

+ For more information, see the{" "} + + PayloadCMS API Keys documentation + + . +

+ +); diff --git a/src/app/(private)/data-sources/new/page.tsx b/src/app/(private)/data-sources/new/page.tsx index 777a9e40..9bace5ed 100644 --- a/src/app/(private)/data-sources/new/page.tsx +++ b/src/app/(private)/data-sources/new/page.tsx @@ -30,6 +30,7 @@ import AirtableFields from "./fields/AirtableFields"; import CSVFields from "./fields/CSVFields"; import GoogleSheetsFields from "./fields/GoogleSheetsFields"; import MailchimpFields from "./fields/MailchimpFields"; +import PayloadCMSFields from "./fields/PayloadCMSFields"; import { type NewDataSourceConfig, defaultStateSchema } from "./schema"; import type { @@ -252,6 +253,8 @@ function ConfigFields({ ); case DataSourceType.Mailchimp: return ; + case DataSourceType.PayloadCMS: + return ; default: return null; } diff --git a/src/app/(private)/data-sources/new/schema.ts b/src/app/(private)/data-sources/new/schema.ts index 6b7b44f0..54a9acb2 100644 --- a/src/app/(private)/data-sources/new/schema.ts +++ b/src/app/(private)/data-sources/new/schema.ts @@ -7,6 +7,7 @@ import { csvConfigSchema, googleSheetsConfigSchema, mailchimpConfigSchema, + payloadCMSConfigSchema, } from "@/server/models/DataSource"; export const newCSVConfigSchema = csvConfigSchema.extend({ @@ -22,6 +23,7 @@ export const newDataSourceConfigSchema = z.discriminatedUnion("type", [ mailchimpConfigSchema, googleSheetsConfigSchema, newCSVConfigSchema, + payloadCMSConfigSchema, ]); export type NewDataSourceConfig = z.infer; diff --git a/src/components/DataSourceIcon.tsx b/src/components/DataSourceIcon.tsx index fd5a1441..e6270e40 100644 --- a/src/components/DataSourceIcon.tsx +++ b/src/components/DataSourceIcon.tsx @@ -293,12 +293,40 @@ const ActionNetworkSVG = () => ( ); +const PayloadCMSSVG = () => ( + + + + + +); + const dataSourceIcons: Record = { [DataSourceType.ActionNetwork]: , [DataSourceType.Airtable]: , [DataSourceType.CSV]: , [DataSourceType.GoogleSheets]: , [DataSourceType.Mailchimp]: , + [DataSourceType.PayloadCMS]: , }; const DataSourceIcon = ({ diff --git a/src/features.ts b/src/features.ts index bff7fa9e..129823c9 100644 --- a/src/features.ts +++ b/src/features.ts @@ -29,4 +29,9 @@ export const DataSourceFeatures: Record< autoImport: true, enrichment: false, }, + [DataSourceType.PayloadCMS]: { + autoEnrich: true, + autoImport: true, + enrichment: false, + }, }; diff --git a/src/labels.ts b/src/labels.ts index 6884424b..9067505d 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -10,6 +10,7 @@ import type { csvConfigSchema, googleSheetsConfigSchema, mailchimpConfigSchema, + payloadCMSConfigSchema, } from "./server/models/DataSource"; import type { DataSourceType } from "@/server/models/DataSource"; import type z from "zod"; @@ -31,11 +32,14 @@ type DataSourceConfigKey = | keyof z.infer | keyof z.infer | keyof z.infer - | keyof z.infer; + | keyof z.infer + | keyof z.infer; export const DataSourceConfigLabels: Record = { apiKey: "API Key", + apiBaseUrl: "API Base URL", baseId: "Base ID", + collectionName: "Collection Name", listId: "List ID", oAuthCredentials: "OAuth Credentials", sheetName: "Sheet Name", @@ -51,6 +55,7 @@ export const DataSourceTypeLabels: Record = { csv: "CSV", googlesheets: "Google Sheets", mailchimp: "Mailchimp", + payloadcms: "PayloadCMS", }; export const EnrichmentSourceTypeLabels: Record = diff --git a/src/server/adaptors/index.ts b/src/server/adaptors/index.ts index aeed154a..269aa4f1 100644 --- a/src/server/adaptors/index.ts +++ b/src/server/adaptors/index.ts @@ -5,6 +5,7 @@ import { AirtableAdaptor } from "./airtable"; import { CSVAdaptor } from "./csv"; import { GoogleSheetsAdaptor } from "./googlesheets"; import { MailchimpAdaptor } from "./mailchimp"; +import { PayloadCMSAdaptor } from "./payloadcms"; import type { DataSourceConfig } from "../models/DataSource"; export const getDataSourceAdaptor = (dataSource: { @@ -35,6 +36,12 @@ export const getDataSourceAdaptor = (dataSource: { ); case DataSourceType.Mailchimp: return new MailchimpAdaptor(id, config.apiKey, config.listId); + case DataSourceType.PayloadCMS: + return new PayloadCMSAdaptor( + config.apiBaseUrl, + config.apiKey, + config.collectionName, + ); default: logger.error(`Unimplemented data source type: ${dataSourceType}`); return null; diff --git a/src/server/adaptors/payloadcms.ts b/src/server/adaptors/payloadcms.ts new file mode 100644 index 00000000..43952ba5 --- /dev/null +++ b/src/server/adaptors/payloadcms.ts @@ -0,0 +1,324 @@ +import { DATA_RECORDS_JOB_BATCH_SIZE } from "@/constants"; +import logger from "@/server/services/logger"; +import type { DataSourceAdaptor } from "./abstract"; +import type { EnrichedRecord } from "@/server/mapping/enrich"; +import type { ExternalRecord, TaggedRecord } from "@/types"; + +/** + * PayloadCMS Data Source Adaptor + * + * Implements integration with PayloadCMS REST API + * Documentation: https://payloadcms.com/docs/rest-api/overview + * Authentication: https://payloadcms.com/docs/authentication/api-keys#api-key-only-auth + */ +export class PayloadCMSAdaptor implements DataSourceAdaptor { + private apiBaseUrl: string; + private apiKey: string; + private collectionName: string; + + constructor(apiBaseUrl: string, apiKey: string, collectionName: string) { + // Ensure the base URL doesn't have a trailing slash + this.apiBaseUrl = apiBaseUrl.replace(/\/$/, ""); + this.apiKey = apiKey; + this.collectionName = collectionName; + } + + /** + * Get the base URL for the collection API endpoint + */ + private getCollectionURL(): URL { + return new URL(`${this.apiBaseUrl}/api/${this.collectionName}`); + } + + /** + * Get common headers for API requests + */ + private getHeaders(): Record { + return { + Authorization: `${this.collectionName} API-Key ${this.apiKey}`, + "Content-Type": "application/json", + }; + } + + /** + * PayloadCMS doesn't have a built-in webhook system like Airtable, + * so this method is not applicable + */ + async *extractExternalRecordIdsFromWebhookBody( + body: unknown, + ): AsyncGenerator { + logger.warn( + "PayloadCMS does not support webhooks through this adaptor. Consider using auto-import instead.", + ); + // No-op: PayloadCMS doesn't have a standard webhook system + } + + /** + * Get the total count of records in the collection + */ + async getRecordCount(): Promise { + try { + const url = this.getCollectionURL(); + url.searchParams.set("limit", "1"); + url.searchParams.set("page", "1"); + + const response = await fetch(url, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + const responseText = await response.text(); + logger.error( + `Bad get record count response: ${response.status}, ${responseText}`, + ); + return null; + } + + const json = (await response.json()) as { + totalDocs?: number; + }; + + return json.totalDocs ?? null; + } catch (error) { + logger.error("Error getting record count from PayloadCMS", { error }); + return null; + } + } + + /** + * Fetch all records from the collection using pagination + */ + async *fetchAll(): AsyncGenerator { + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + try { + const url = this.getCollectionURL(); + url.searchParams.set("limit", "100"); + url.searchParams.set("page", String(page)); + url.searchParams.set("depth", "0"); // Don't populate relationships + + const response = await fetch(url, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error( + `Bad fetch all response: ${response.status}, ${responseText}`, + ); + } + + const json = (await response.json()) as { + docs: Array<{ id: string; [key: string]: unknown }>; + hasNextPage: boolean; + }; + + if (!Array.isArray(json.docs)) { + throw new Error(`Invalid response format: docs is not an array`); + } + + for (const doc of json.docs) { + if (doc.id) { + // Create a copy without the id field for the json property + const { id, ...fields } = doc; + yield { + externalId: String(id), + json: fields, + }; + } + } + + hasNextPage = json.hasNextPage ?? false; + page++; + } catch (error) { + logger.error(`Error fetching page ${page} from PayloadCMS`, { error }); + throw error; + } + } + } + + /** + * Fetch the first record from the collection + */ + async fetchFirst(): Promise { + try { + const url = this.getCollectionURL(); + url.searchParams.set("limit", "1"); + url.searchParams.set("page", "1"); + url.searchParams.set("depth", "0"); // Don't populate relationships + + const response = await fetch(url, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error( + `Bad fetch first response: ${response.status}, ${responseText}`, + ); + } + + const json = (await response.json()) as { + docs: Array<{ id: string; [key: string]: unknown }>; + }; + + if (!Array.isArray(json.docs) || json.docs.length === 0) { + return null; + } + + const doc = json.docs[0]; + const { id, ...fields } = doc; + + return { + externalId: String(id), + json: fields, + }; + } catch (error) { + logger.error("Error fetching first record from PayloadCMS", { error }); + return null; + } + } + + /** + * Fetch specific records by their external IDs + */ + async fetchByExternalId(externalIds: string[]): Promise { + if (externalIds.length > DATA_RECORDS_JOB_BATCH_SIZE) { + throw new Error( + `Cannot fetch more than ${DATA_RECORDS_JOB_BATCH_SIZE} records at once.`, + ); + } + + const results: ExternalRecord[] = []; + + // PayloadCMS doesn't support batch fetching in a single request, + // so we need to fetch each record individually + for (const externalId of externalIds) { + try { + const url = new URL(`${this.apiBaseUrl}/api/${this.collectionName}/${externalId}`); + + const response = await fetch(url, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + logger.warn( + `Failed to fetch record ${externalId}: ${response.status}`, + ); + continue; + } + + const doc = (await response.json()) as { + id: string; + [key: string]: unknown; + }; + + const { id, ...fields } = doc; + results.push({ + externalId: String(id), + json: fields, + }); + } catch (error) { + logger.error(`Error fetching record ${externalId} from PayloadCMS`, { + error, + }); + } + } + + return results; + } + + /** + * PayloadCMS doesn't have a concept of dev webhooks + */ + async removeDevWebhooks(): Promise { + // No-op: PayloadCMS doesn't have webhooks in the standard API + } + + /** + * PayloadCMS doesn't support webhooks through the standard REST API + */ + async toggleWebhook(enable: boolean): Promise { + if (enable) { + logger.warn( + "PayloadCMS does not support webhooks through the REST API. Consider enabling auto-import for this data source.", + ); + } + // No-op: PayloadCMS doesn't have webhooks in the standard API + } + + /** + * Update records in PayloadCMS with enriched data + */ + async updateRecords(enrichedRecords: EnrichedRecord[]): Promise { + for (const record of enrichedRecords) { + try { + const url = new URL( + `${this.apiBaseUrl}/api/${this.collectionName}/${record.externalRecord.externalId}`, + ); + + const fields: Record = {}; + for (const column of record.columns) { + fields[column.def.name] = column.value; + } + + const response = await fetch(url, { + method: "PATCH", + headers: this.getHeaders(), + body: JSON.stringify(fields), + }); + + if (!response.ok) { + const responseText = await response.text(); + logger.error( + `Failed to update record ${record.externalRecord.externalId}: ${response.status}, ${responseText}`, + ); + } + } catch (error) { + logger.error( + `Error updating record ${record.externalRecord.externalId} in PayloadCMS`, + { error }, + ); + } + } + } + + /** + * Tag records in PayloadCMS with boolean fields + */ + async tagRecords(taggedRecords: TaggedRecord[]): Promise { + if (!taggedRecords.length) { + return; + } + + for (const record of taggedRecords) { + try { + const url = new URL( + `${this.apiBaseUrl}/api/${this.collectionName}/${record.externalId}`, + ); + + const response = await fetch(url, { + method: "PATCH", + headers: this.getHeaders(), + body: JSON.stringify({ + [record.tag.name]: record.tag.present, + }), + }); + + if (!response.ok) { + const responseText = await response.text(); + logger.error( + `Failed to tag record ${record.externalId}: ${response.status}, ${responseText}`, + ); + } + } catch (error) { + logger.error( + `Error tagging record ${record.externalId} in PayloadCMS`, + { error }, + ); + } + } + } +} diff --git a/src/server/models/DataSource.ts b/src/server/models/DataSource.ts index 7c693526..e03f3a72 100644 --- a/src/server/models/DataSource.ts +++ b/src/server/models/DataSource.ts @@ -26,6 +26,7 @@ export enum DataSourceType { CSV = "csv", GoogleSheets = "googlesheets", Mailchimp = "mailchimp", + PayloadCMS = "payloadcms", } export const dataSourceTypes = Object.values(DataSourceType); @@ -80,12 +81,22 @@ export const csvConfigSchema = z.object({ export type CSVConfig = z.infer; +export const payloadCMSConfigSchema = z.object({ + type: z.literal(DataSourceType.PayloadCMS), + apiBaseUrl: z.string().nonempty(), + apiKey: z.string().nonempty(), + collectionName: z.string().nonempty(), +}); + +export type PayloadCMSConfig = z.infer; + export const dataSourceConfigSchema = z.discriminatedUnion("type", [ actionNetworkConfigSchema, airtableConfigSchema, googleSheetsConfigSchema, mailchimpConfigSchema, csvConfigSchema, + payloadCMSConfigSchema, ]); export type DataSourceConfig = z.infer; diff --git a/tests/unit/server/adaptors/payloadcms.test.ts b/tests/unit/server/adaptors/payloadcms.test.ts new file mode 100644 index 00000000..8394f0fa --- /dev/null +++ b/tests/unit/server/adaptors/payloadcms.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { PayloadCMSAdaptor } from "@/server/adaptors/payloadcms"; + +describe("PayloadCMS adaptor tests", () => { + const mockConfig = { + apiBaseUrl: "https://example.com", + apiKey: "test-api-key", + collectionName: "posts", + }; + + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + test("Constructor trims trailing slash from apiBaseUrl", () => { + const adaptor = new PayloadCMSAdaptor( + "https://example.com/", + mockConfig.apiKey, + mockConfig.collectionName, + ); + expect(adaptor["apiBaseUrl"]).toBe("https://example.com"); + }); + + test("getRecordCount returns total document count", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + docs: [], + totalDocs: 42, + hasNextPage: false, + }), + })); + + const count = await adaptor.getRecordCount(); + expect(count).toBe(42); + }); + + test("getRecordCount returns null on error", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => "Unauthorized", + })); + + const count = await adaptor.getRecordCount(); + expect(count).toBeNull(); + }); + + test("fetchFirst returns first record", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + const mockDoc = { + id: "123", + title: "Test Post", + content: "Test content", + }; + + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + docs: [mockDoc], + totalDocs: 1, + hasNextPage: false, + }), + })); + + const firstRow = await adaptor.fetchFirst(); + expect(firstRow).toEqual({ + externalId: "123", + json: { + title: "Test Post", + content: "Test content", + }, + }); + }); + + test("fetchFirst returns null when no records", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + docs: [], + totalDocs: 0, + hasNextPage: false, + }), + })); + + const firstRow = await adaptor.fetchFirst(); + expect(firstRow).toBeNull(); + }); + + test("fetchAll yields all records with pagination", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + docs: [ + { id: "1", title: "Post 1" }, + { id: "2", title: "Post 2" }, + ], + hasNextPage: true, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + docs: [ + { id: "3", title: "Post 3" }, + ], + hasNextPage: false, + }), + }), + ); + + const results = []; + for await (const rec of adaptor.fetchAll()) { + results.push(rec); + } + + expect(results).toHaveLength(3); + expect(results[0]).toEqual({ + externalId: "1", + json: { title: "Post 1" }, + }); + expect(results[1]).toEqual({ + externalId: "2", + json: { title: "Post 2" }, + }); + expect(results[2]).toEqual({ + externalId: "3", + json: { title: "Post 3" }, + }); + }); + + test("fetchByExternalId returns specific records", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: "123", + title: "Specific Post", + content: "Specific content", + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: "456", + title: "Another Post", + content: "Another content", + }), + }), + ); + + const results = await adaptor.fetchByExternalId(["123", "456"]); + expect(results).toHaveLength(2); + expect(results[0].externalId).toBe("123"); + expect(results[1].externalId).toBe("456"); + }); + + test("fetchByExternalId handles missing records", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: "123", + title: "Found Post", + }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => "Not found", + }), + ); + + const results = await adaptor.fetchByExternalId(["123", "999"]); + expect(results).toHaveLength(1); + expect(results[0].externalId).toBe("123"); + }); + + test("fetchByExternalId throws error for too many records", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + const tooManyIds = Array.from({ length: 101 }, (_, i) => String(i)); + await expect(adaptor.fetchByExternalId(tooManyIds)).rejects.toThrow( + "Cannot fetch more than", + ); + }); + + test("updateRecords updates records via PATCH", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + }); + vi.stubGlobal("fetch", mockFetch); + + const enrichedRecords = [ + { + externalRecord: { + externalId: "123", + json: { title: "Old Title" }, + }, + columns: [ + { + def: { name: "enrichedField", type: "String" as const }, + value: "enriched value", + }, + ], + }, + ]; + + await adaptor.updateRecords(enrichedRecords); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ enrichedField: "enriched value" }), + }), + ); + }); + + test("tagRecords updates records with tag fields", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + }); + vi.stubGlobal("fetch", mockFetch); + + const taggedRecords = [ + { + externalId: "123", + json: { title: "Post" }, + tag: { + name: "Featured", + present: true, + }, + }, + ]; + + await adaptor.tagRecords(taggedRecords); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ Featured: true }), + }), + ); + }); + + test("tagRecords handles empty array", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + await adaptor.tagRecords([]); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test("removeDevWebhooks is a no-op", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + // Should not throw + await expect(adaptor.removeDevWebhooks()).resolves.toBeUndefined(); + }); + + test("toggleWebhook is a no-op but logs warning when enabled", async () => { + const adaptor = new PayloadCMSAdaptor( + mockConfig.apiBaseUrl, + mockConfig.apiKey, + mockConfig.collectionName, + ); + + // Should not throw + await expect(adaptor.toggleWebhook(true)).resolves.toBeUndefined(); + await expect(adaptor.toggleWebhook(false)).resolves.toBeUndefined(); + }); +});