import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { Builder } from "./builder.js"; import { createDatabase } from "./database.js"; import { ExecutionError, RateLimitError } from "./error.js"; import { createPacer } from "./pacer.js"; import { Schema } from "./schema.js"; import { createSync, type KeyPropertyName } from "./sync.js"; const issues = createDatabase("issues", { schema: { Name: Schema.title(), "GitHub ID": Schema.richText(), Closed: Schema.checkbox(), }, }); const projects = createDatabase("projects", { schema: { Name: Schema.title(), "Project ID": Schema.richText(), }, }); const keyTypes = createDatabase("key-types", { schema: { Name: Schema.title(), ID: Schema.richText(), Closed: Schema.checkbox(), Count: Schema.number(), Due: Schema.date(), Project: Schema.relation(projects), }, }); const partialMeetings = createDatabase("partial-meetings", { schema: { Name: Schema.title(), "Meeting ID": Schema.richText(), Other: Schema.richText(), "Meeting date": Schema.date(), "Prep complete": Schema.checkbox(), }, }); const keyOnly = createDatabase("key-only", { schema: { ID: Schema.richText(), }, }); type IssueKey = KeyPropertyName; const textKey: IssueKey = "GitHub ID"; const titleKey: IssueKey = "Name"; // @ts-expect-error Checkboxes cannot be sync primary keys. const checkboxKey: IssueKey = "Closed"; // @ts-expect-error The primary key must exist in the schema. const missingKey: IssueKey = "Missing"; void [textKey, titleKey, checkboxKey, missingKey]; createSync({ database: keyTypes, // @ts-expect-error Checkboxes cannot be sync primary keys. primaryKey: "Closed", handler: async () => ({ changes: [] }), }); createSync({ database: keyTypes, // @ts-expect-error Numbers cannot be sync primary keys. primaryKey: "Count", handler: async () => ({ changes: [] }), }); createSync({ database: keyTypes, // @ts-expect-error Dates cannot be sync primary keys. primaryKey: "Due", handler: async () => ({ changes: [] }), }); createSync({ database: keyTypes, // @ts-expect-error Relations cannot be sync primary keys. primaryKey: "Project", handler: async () => ({ changes: [] }), }); createSync({ database: keyTypes, // @ts-expect-error The primary key must be present in the database schema. primaryKey: "Missing", handler: async () => ({ changes: [] }), }); let stdoutSpy: Mock; let inputDirectory: string | undefined; beforeEach(() => { stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); }); afterEach(async () => { stdoutSpy.mockRestore(); vi.unstubAllEnvs(); if (inputDirectory !== undefined) { await rm(inputDirectory, { recursive: true, force: true }); inputDirectory = undefined; } }); describe("createSync", () => { it("stores its primary key on the sync config", () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", mode: "replace", schedule: "1h", handler: async () => ({ changes: [] }), }); expect(sync.config).toEqual({ database: issues, primaryKeyProperty: "GitHub ID", mode: "replace", schedule: { type: "interval", intervalMs: 3_600_000 }, }); }); it("accepts title and rich-text primary keys", () => { expect( createSync({ database: issues, primaryKey: "Name", handler: async () => ({ changes: [] }), }).config.primaryKeyProperty, ).toBe("Name"); expect( createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => ({ changes: [] }), }).config.primaryKeyProperty, ).toBe("GitHub ID"); }); it("accepts partial upsert properties in standalone handlers", () => { const finiteProperties = { Name: Builder.title("Prepared"), Unknown: Builder.text("unexpected"), }; const spreadProperties = { Unknown: Builder.text("unexpected from spread"), }; createSync({ database: partialMeetings, primaryKey: "Meeting ID", handler: async (state: { cursor: number } | undefined) => ({ changes: [ { type: "upsert", key: "meeting-1", properties: { Name: Builder.title("One") }, }, { type: "upsert", key: "meeting-2", properties: { "Meeting date": Builder.date("2026-09-16"), "Prep complete": Builder.checkbox(true), }, }, { type: "delete", key: "meeting-3" }, ], nextState: { cursor: (state?.cursor ?? 0) + 1 }, }), }); const invalidSyncDeclarations = () => { createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Unknown property keys are rejected in inferred handler returns. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-inline", properties: { Name: Builder.title("Known"), Unknown: Builder.text("unexpected"), }, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Unknown properties remain rejected when another change shares the key. handler: async () => ({ changes: [ { type: "upsert", key: "same", properties: { Name: Builder.title("Known"), Unknown: Builder.text("unexpected"), }, }, { type: "upsert", key: "same", properties: { Name: Builder.title("Known sibling") }, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Upsert properties must include a non-primary-key schema property. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-empty", properties: {}, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Finite property objects cannot contain unknown keys. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-finite", properties: finiteProperties, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Spread property objects cannot contain unknown keys. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-spread", properties: { Name: Builder.title("Known from spread"), ...spreadProperties, }, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error Known properties must use their declared value types. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-value", properties: { Name: Builder.people("person@example.com"), }, }, ], }), }); createSync({ database: partialMeetings, primaryKey: "Meeting ID", // @ts-expect-error The primary-key property is supplied from change.key. handler: async () => ({ changes: [ { type: "upsert", key: "invalid-primary-property", properties: { "Meeting ID": Builder.text("explicit"), }, }, ], }), }); createSync({ database: partialMeetings, // @ts-expect-error Unknown schema properties cannot be sync primary keys. primaryKey: "Unknown", handler: async () => ({ changes: [] }), }); }; void invalidSyncDeclarations; }); it("rejects key-only upserts but allows deletes", () => { const invalidKeyOnlyUpsert = () => { createSync({ database: keyOnly, primaryKey: "ID", // @ts-expect-error Upserts require at least one non-primary-key schema property. handler: async () => ({ changes: [{ type: "upsert", key: "invalid-key-only", properties: {} }], }), }); }; void invalidKeyOnlyUpsert; createSync({ database: keyOnly, primaryKey: "ID", handler: async () => ({ changes: [{ type: "delete", key: "deleted-key-only" }], }), }); }); it("rejects invalid schedules when the sync is created", () => { expect(() => createSync({ database: issues, primaryKey: "GitHub ID", schedule: "30d", handler: async () => ({ changes: [] }), }), ).toThrow(/at most 7 days/); }); it("writes the key property and replaces an unsafe handler value", async () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => ({ changes: [ { type: "upsert", key: "issue-123", properties: { Name: Builder.title("Fix the bug"), Closed: Builder.checkbox(false), ...({ "GitHub ID": Builder.richText("wrong") } as Record< string, unknown >), }, }, ], }), }); const result = await sync.handler(undefined, { concreteOutput: true }); expect(result.changes).toEqual([ { type: "upsert", key: "issue-123", properties: { Name: [["Fix the bug"]], Closed: [["No"]], "GitHub ID": [["issue-123"]], }, targetDatabaseKey: "issues", }, ]); }); it("preserves omitted properties, partial upserts, and deletes at runtime", async () => { const sync = createSync({ database: partialMeetings, primaryKey: "Meeting ID", handler: async (state: { cursor: number } | undefined) => ({ changes: [ { type: "upsert", key: "meeting-1", properties: { Name: Builder.title("One") }, }, { type: "upsert", key: "meeting-2", properties: { Other: Builder.text("Only other") }, }, { type: "upsert", key: "meeting-3", properties: { "Meeting date": Builder.date("2026-09-16"), "Prep complete": Builder.checkbox(true), }, }, { type: "delete", key: "meeting-4" }, ], nextState: { cursor: (state?.cursor ?? 0) + 1 }, }), }); const result = await sync.handler({ state: { cursor: 4 } }, { concreteOutput: true }); expect(result).toEqual({ changes: [ { type: "upsert", key: "meeting-1", properties: { Name: [["One"]], "Meeting ID": [["meeting-1"]], }, targetDatabaseKey: "partial-meetings", }, { type: "upsert", key: "meeting-2", properties: { Other: [["Only other"]], "Meeting ID": [["meeting-2"]], }, targetDatabaseKey: "partial-meetings", }, { type: "upsert", key: "meeting-3", properties: { "Meeting date": [ ["‣", [["d", { type: "date", start_date: "2026-09-16" }]]], ], "Prep complete": [["Yes"]], "Meeting ID": [["meeting-3"]], }, targetDatabaseKey: "partial-meetings", }, { type: "delete", key: "meeting-4", targetDatabaseKey: "partial-meetings", }, ], hasMore: false, nextUserContext: { cursor: 5 }, nextPacerStates: {}, }); }); it("rejects empty upserts at runtime", async () => { const sync = createSync({ database: partialMeetings, primaryKey: "Meeting ID", handler: async () => ({ changes: [ { type: "upsert", key: "meeting-empty", properties: {}, icon: Builder.notionIcon("warning"), cover: Builder.imageCover("https://example.com/empty-cover.png"), pageContentMarkdown: "# Empty properties", }, ], }), } as never); await expect(sync.handler(undefined, { concreteOutput: true })).rejects.toBeInstanceOf( ExecutionError, ); }); it("passes deletes, paging state, and incremental mode through", async () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", mode: "incremental", handler: async (state: { cursor: string } | undefined) => ({ changes: [{ type: "delete", key: "issue-123" }], hasMore: true, nextState: { cursor: `${state?.cursor ?? ""}next` }, }), }); const result = await sync.handler({ state: { cursor: "page-" } }, { concreteOutput: true }); expect(sync.config.mode).toBe("incremental"); expect(result).toEqual({ changes: [{ type: "delete", key: "issue-123", targetDatabaseKey: "issues" }], hasMore: true, nextUserContext: { cursor: "page-next" }, nextPacerStates: {}, }); }); it("uses file-backed runtime state instead of inline state", async () => { const handler = vi.fn(async (state: { cursor: string } | undefined) => { if (state === undefined) throw new Error("Expected sync state"); return { changes: [], nextState: state }; }); const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler, }); inputDirectory = await mkdtemp(join(tmpdir(), "notion-apps-sync-input-")); const inputPath = join(inputDirectory, "input.json"); await writeFile(inputPath, JSON.stringify({ state: { cursor: "file" } })); vi.stubEnv("NOTION_INPUT_PAYLOAD_PATH", inputPath); const result = await sync.handler( { state: { cursor: "inline" } }, { concreteOutput: true }, ); expect(handler).toHaveBeenCalledWith({ cursor: "file" }, expect.anything()); expect(result.nextUserContext).toEqual({ cursor: "file" }); }); it("passes relations, icons, covers, and page content through", async () => { const tasks = createDatabase("tasks", { schema: { Name: Schema.title(), ID: Schema.richText(), Project: Schema.relation(projects), }, }); const sync = createSync({ database: tasks, primaryKey: "ID", handler: async () => ({ changes: [ { type: "upsert", key: "task-1", properties: { Name: Builder.title("Write tests"), Project: [Builder.relation("project-1")], }, icon: Builder.emojiIcon("✅"), cover: Builder.imageCover("https://example.com/cover.png"), pageContentMarkdown: "# Details", }, ], }), }); const result = await sync.handler(undefined, { concreteOutput: true }); expect(result.changes).toEqual([ { type: "upsert", key: "task-1", properties: { Name: [["Write tests"]], ID: [["task-1"]], Project: [{ type: "primaryKey", value: "project-1" }], }, icon: { type: "emoji", value: "✅" }, cover: { type: "image", url: "https://example.com/cover.png", position: 0.5, }, pageContentMarkdown: "# Details", targetDatabaseKey: "tasks", }, ]); }); it("returns the result and writes a success envelope", async () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => ({ changes: [] }), }); await expect(sync.handler()).resolves.toEqual({ changes: [], hasMore: false, nextUserContext: undefined, nextPacerStates: {}, }); expect(stdoutSpy).toHaveBeenCalledWith( '\n<__notion_output__>{"_tag":"success","value":{"changes":[],"hasMore":false,"nextPacerStates":{}}}\n', ); }); it("threads platform pacer state through the handler", async () => { const pacer = createPacer("sync-test-pacer", { allowedRequests: 10, intervalMs: 1_000, }); const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => { await pacer.wait(); return { changes: [] }; }, }); const result = await sync.handler( { pacers: { [pacer.name]: { lastScheduledAtMs: 0, allowedRequests: 5, intervalMs: 500, }, }, }, { concreteOutput: true }, ); expect(result.nextPacerStates[pacer.name]).toMatchObject({ allowedRequests: 5, intervalMs: 500, lastScheduledAtMs: expect.any(Number), }); }); it("writes a rate-limit envelope and rethrows the rate-limit error", async () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => { throw new RateLimitError({ retryAfter: 30 }); }, }); await expect(sync.handler()).rejects.toBeInstanceOf(RateLimitError); expect(stdoutSpy).toHaveBeenCalledWith( '\n<__notion_output__>{"_tag":"error","error":{"_tag":"rate_limit","name":"RateLimitError","message":"Error during app execution: Rate limited by external API","retryAfter":30}}\n', ); }); it("writes a generic error envelope and throws ExecutionError", async () => { const sync = createSync({ database: issues, primaryKey: "GitHub ID", handler: async () => { throw new Error("boom"); }, }); await expect(sync.handler()).rejects.toBeInstanceOf(ExecutionError); expect(stdoutSpy).toHaveBeenCalledWith( '\n<__notion_output__>{"_tag":"error","error":{"_tag":"generic","name":"ExecutionError","message":"Error during app execution: Error: boom"}}\n', ); }); });