From f94f25aa47b5c8e7f78c9f770a723af6883b3b51 Mon Sep 17 00:00:00 2001 From: luandro Date: Mon, 9 Mar 2026 18:36:08 -0400 Subject: [PATCH] feat(api): add fetch-one job flow --- api-server/api-docs.test.ts | 12 +-- .../api-documentation-validation.test.ts | 6 +- api-server/api-routes.validation.test.ts | 29 +++---- api-server/audit-logging-integration.test.ts | 2 +- api-server/audit.test.ts | 8 +- api-server/content-repo.ts | 5 +- api-server/docker-runtime-smoke-tests.test.ts | 8 +- api-server/endpoint-schema-validation.test.ts | 30 +++---- api-server/fetch-job-runner.test.ts | 54 +++++++++++++ api-server/fetch-job-runner.ts | 18 ++++- .../github-status-callback-flow.test.ts | 64 +++++++-------- api-server/github-status-idempotency.test.ts | 26 +++---- api-server/github-status.test.ts | 24 ++---- api-server/handler-integration.test.ts | 26 +++---- api-server/http-integration.test.ts | 28 +++---- api-server/index.test.ts | 66 ++++++++-------- api-server/input-validation.test.ts | 34 ++++---- api-server/job-executor-core.test.ts | 23 +++--- api-server/job-executor-timeout.test.ts | 35 +++++---- api-server/job-executor.ts | 31 +++++++- .../job-persistence-deterministic.test.ts | 50 ++++++------ api-server/job-persistence-race.test.ts | 14 ++-- api-server/job-persistence.test.ts | 42 +++++----- api-server/job-tracker.test.ts | 42 +++++----- api-server/job-tracker.ts | 9 ++- api-server/routes/job-types.ts | 1 + api-server/validation-schemas.test.ts | 67 ++++++++++++---- api-server/validation-schemas.ts | 30 +++++-- .../__tests__/index.cli.test.ts | 78 ++++++++++++++++--- scripts/notion-fetch-all/fetchAll.test.ts | 39 ++++++++++ scripts/notion-fetch-all/fetchAll.ts | 16 ++++ scripts/notion-fetch-all/index.ts | 9 ++- 32 files changed, 596 insertions(+), 330 deletions(-) diff --git a/api-server/api-docs.test.ts b/api-server/api-docs.test.ts index aa42bf05..3593bee4 100644 --- a/api-server/api-docs.test.ts +++ b/api-server/api-docs.test.ts @@ -68,8 +68,8 @@ describe("API Documentation Endpoint", () => { it("should include all required paths", () => { const validJobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", @@ -353,8 +353,8 @@ describe("API Documentation Endpoint", () => { it("should define Job schema", () => { const validJobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", @@ -399,8 +399,8 @@ describe("API Documentation Endpoint", () => { it("should define CreateJobRequest schema", () => { const validJobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", diff --git a/api-server/api-documentation-validation.test.ts b/api-server/api-documentation-validation.test.ts index b62ade37..0947cbf2 100644 --- a/api-server/api-documentation-validation.test.ts +++ b/api-server/api-documentation-validation.test.ts @@ -172,7 +172,7 @@ describe("API Documentation Validation", () => { items: [ { id: "job-123", - type: "notion:fetch" as const, + type: "fetch-one" as const, status: "completed" as const, createdAt: "2025-02-06T10:00:00.000Z", startedAt: "2025-02-06T10:00:01.000Z", @@ -215,7 +215,7 @@ describe("API Documentation Validation", () => { const jobWithProgress = { id: "job-123", - type: "notion:fetch-all" as const, + type: "fetch-all" as const, status: "running" as const, createdAt: "2025-02-06T12:00:00.000Z", startedAt: "2025-02-06T12:00:01.000Z", @@ -418,7 +418,7 @@ describe("API Documentation Validation", () => { describe("Job Tracker Integration", () => { it("should produce data matching job schema", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); const job = tracker.getJob(jobId); expect(job).toBeDefined(); diff --git a/api-server/api-routes.validation.test.ts b/api-server/api-routes.validation.test.ts index 46efc387..b76a8389 100644 --- a/api-server/api-routes.validation.test.ts +++ b/api-server/api-routes.validation.test.ts @@ -53,8 +53,8 @@ describe("API Routes - Validation", () => { describe("Job Types Validation", () => { const validJobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", @@ -82,11 +82,14 @@ describe("API Routes - Validation", () => { it("should have correct job type descriptions", () => { const expectedDescriptions: Record = { - "fetch-ready": "Fetch ready pages from Notion", - "fetch-all": "Fetch all pages from Notion", + "fetch-one": "Fetch a single page from Notion by page ID", + "fetch-ready": + 'Fetch pages with status "Ready to publish" and transition to "Draft published"', + "fetch-all": + 'Fetch all pages except status "Remove" and sync generated artifacts', "notion:fetch": "Fetch pages from Notion", "notion:fetch-all": "Fetch all pages from Notion", - "notion:count-pages": "Count pages from Notion", + "notion:count-pages": "Count pages in Notion database", "notion:translate": "Translate content", "notion:status-translation": "Update status for translation workflow", "notion:status-draft": "Update status for draft publish workflow", @@ -126,7 +129,7 @@ describe("API Routes - Validation", () => { it("should return correct job list response shape", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); const jobs = tracker.getAllJobs(); @@ -159,7 +162,7 @@ describe("API Routes - Validation", () => { it("should return correct job creation response shape", () => { const tracker = getJobTracker(); - const jobType: JobType = "notion:fetch-all"; + const jobType: JobType = "fetch-all"; const jobId = tracker.createJob(jobType); const expectedResponse = { @@ -257,7 +260,7 @@ describe("API Routes - Validation", () => { ] as const; const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); // Test each status transition tracker.updateJobStatus(jobId, "running"); @@ -272,7 +275,7 @@ describe("API Routes - Validation", () => { it("should handle failed job status with error result", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobStatus(jobId, "running"); tracker.updateJobStatus(jobId, "failed", { @@ -290,8 +293,8 @@ describe("API Routes - Validation", () => { describe("Request Validation", () => { it("should validate job type in request body", () => { const validJobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", @@ -305,14 +308,14 @@ describe("API Routes - Validation", () => { return validJobTypes.includes(type as JobType); }; - expect(isValidJobType("notion:fetch")).toBe(true); + expect(isValidJobType("fetch-one")).toBe(true); expect(isValidJobType("invalid:type")).toBe(false); expect(isValidJobType("")).toBe(false); }); it("should accept optional options in request body", () => { const requestBody = { - type: "notion:fetch-all" as JobType, + type: "fetch-all" as JobType, options: { maxPages: 10, statusFilter: "In Progress", diff --git a/api-server/audit-logging-integration.test.ts b/api-server/audit-logging-integration.test.ts index 34db012a..60158678 100644 --- a/api-server/audit-logging-integration.test.ts +++ b/api-server/audit-logging-integration.test.ts @@ -85,7 +85,7 @@ describe("Audit Logging Integration", () => { authorization: `Bearer ${TEST_API_KEY}`, "x-forwarded-for": "192.168.1.100", }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); // Authenticate request diff --git a/api-server/audit.test.ts b/api-server/audit.test.ts index 87017394..634915ea 100644 --- a/api-server/audit.test.ts +++ b/api-server/audit.test.ts @@ -135,7 +135,7 @@ describe("AuditLogger", () => { it("should capture query parameters", () => { const req = new Request( - "http://localhost:3001/jobs?status=running&type=notion:fetch", + "http://localhost:3001/jobs?status=running&type=fetch-one", { method: "GET", } @@ -147,7 +147,7 @@ describe("AuditLogger", () => { }; const entry = audit.createEntry(req, authResult); - expect(entry.query).toBe("?status=running&type=notion:fetch"); + expect(entry.query).toBe("?status=running&type=fetch-one"); }); }); @@ -616,7 +616,7 @@ describe("AuditLogger", () => { ); const req = new Request( - "http://localhost:3001/jobs?status=running&type=notion:fetch", + "http://localhost:3001/jobs?status=running&type=fetch-one", { method: "GET" } ); @@ -633,7 +633,7 @@ describe("AuditLogger", () => { const logContents = readFileSync(logPath, "utf-8"); const logEntry = JSON.parse(logContents.trim()); - expect(logEntry.query).toBe("?status=running&type=notion:fetch"); + expect(logEntry.query).toBe("?status=running&type=fetch-one"); }); it("should append multiple entries for multiple requests", async () => { diff --git a/api-server/content-repo.ts b/api-server/content-repo.ts index 96427c06..6a5410e4 100644 --- a/api-server/content-repo.ts +++ b/api-server/content-repo.ts @@ -364,7 +364,7 @@ export async function assertCleanWorkingTree(force: boolean): Promise { } export async function prepareContentBranchForFetch( - mode: "fetch-ready" | "fetch-all" + mode: "fetch-one" | "fetch-ready" | "fetch-all" ): Promise<{ remoteRef: string; }> { @@ -937,10 +937,9 @@ export async function runContentTask( export function isContentMutatingJob(jobType: string): boolean { return ( + jobType === "fetch-one" || jobType === "fetch-ready" || jobType === "fetch-all" || - jobType === "notion:fetch" || - jobType === "notion:fetch-all" || jobType === "notion:translate" ); } diff --git a/api-server/docker-runtime-smoke-tests.test.ts b/api-server/docker-runtime-smoke-tests.test.ts index aae97c74..c1453416 100644 --- a/api-server/docker-runtime-smoke-tests.test.ts +++ b/api-server/docker-runtime-smoke-tests.test.ts @@ -434,8 +434,8 @@ describe("Docker Runtime Smoke Tests", () => { // Verify known job types are present const typeIds = body.data.types.map((t: { id: string }) => t.id); - expect(typeIds).toContain("notion:fetch"); - expect(typeIds).toContain("notion:fetch-all"); + expect(typeIds).toContain("fetch-one"); + expect(typeIds).toContain("fetch-all"); }); }); @@ -457,7 +457,7 @@ describe("Docker Runtime Smoke Tests", () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ type: "notion:fetch-all" }), + body: JSON.stringify({ type: "fetch-all" }), }); // Should require authentication @@ -484,7 +484,7 @@ describe("Docker Runtime Smoke Tests", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - type: "notion:fetch-all", + type: "fetch-all", options: { dryRun: true }, }), }); diff --git a/api-server/endpoint-schema-validation.test.ts b/api-server/endpoint-schema-validation.test.ts index ddf33837..41f7a42b 100644 --- a/api-server/endpoint-schema-validation.test.ts +++ b/api-server/endpoint-schema-validation.test.ts @@ -202,7 +202,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { describe("Request body validation - options field", () => { it("should reject invalid options type", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: "not-an-object", }); expect(result.success).toBe(false); @@ -215,7 +215,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should reject unknown option keys", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { unknownOption: "value", }, @@ -228,7 +228,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should reject invalid maxPages type", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { maxPages: "not-a-number", }, @@ -245,7 +245,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should allow zero maxPages", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { maxPages: 0, }, @@ -258,7 +258,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should reject non-integer maxPages", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { maxPages: 10.5, }, @@ -274,7 +274,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should reject empty statusFilter", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { statusFilter: "", }, @@ -293,7 +293,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { for (const option of booleanOptions) { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", options: { [option]: "not-a-boolean", }, @@ -311,18 +311,18 @@ describe("Endpoint Schema Validation - POST /jobs", () => { it("should accept valid request with minimal fields", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch", + type: "fetch-one", }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.type).toBe("notion:fetch"); + expect(result.data.type).toBe("fetch-one"); expect(result.data.options).toBeUndefined(); } }); it("should accept valid request with all options", () => { const result = safeValidate(createJobRequestSchema, { - type: "notion:fetch-all", + type: "fetch-all", options: { maxPages: 10, statusFilter: "In Progress", @@ -333,7 +333,7 @@ describe("Endpoint Schema Validation - POST /jobs", () => { }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.type).toBe("notion:fetch-all"); + expect(result.data.type).toBe("fetch-all"); expect(result.data.options?.maxPages).toBe(10); } }); @@ -391,12 +391,12 @@ describe("Endpoint Schema Validation - GET /jobs", () => { it("should accept both filters together", () => { const result = safeValidate(jobsQuerySchema, { status: "completed", - type: "notion:fetch", + type: "fetch-one", }); expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe("completed"); - expect(result.data.type).toBe("notion:fetch"); + expect(result.data.type).toBe("fetch-one"); } }); @@ -659,7 +659,7 @@ describe("Endpoint Schema Validation - Response Schemas", () => { items: [ { id: "job-123", - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: new Date().toISOString(), startedAt: new Date().toISOString(), @@ -681,7 +681,7 @@ describe("Endpoint Schema Validation - Response Schemas", () => { it("should validate create job response schema", () => { const createJobResponse = { jobId: "job-123", - type: "notion:fetch", + type: "fetch-one", status: "pending", message: "Job created successfully", _links: { diff --git a/api-server/fetch-job-runner.test.ts b/api-server/fetch-job-runner.test.ts index 50865a05..6c046346 100644 --- a/api-server/fetch-job-runner.test.ts +++ b/api-server/fetch-job-runner.test.ts @@ -261,6 +261,27 @@ describe("fetch-job-runner", () => { expect(mockVerifyRemoteHeadMatchesLocal).not.toHaveBeenCalled(); }); + it("passes pageId and includeRemoved args for fetch-one", async () => { + const result = await runFetchJob({ + type: "fetch-one", + jobId: "job-fetch-one", + options: { pageId: "page-123", includeRemoved: true, dryRun: true }, + onProgress: vi.fn(), + logger: createLogger(), + childEnv: process.env, + signal: new AbortController().signal, + timeoutMs: 20 * 60 * 1000, + }); + + expect(result.success).toBe(true); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(["--page-id", "page-123", "--include-removed"]) + ); + expect(mockNotionPagesUpdate).not.toHaveBeenCalled(); + expect(mockVerifyRemoteHeadMatchesLocal).not.toHaveBeenCalled(); + }); + it("returns CONTENT_GENERATION_FAILED when staging fails", async () => { mockStageGeneratedPaths.mockRejectedValue( new ContentRepoError( @@ -444,4 +465,37 @@ describe("fetch-job-runner", () => { expect.arrayContaining(["--force", "--dry-run"]) ); }); + + it("passes fetch-all statusFilter and includeRemoved args to generation script", async () => { + const result = await runFetchJob({ + type: "fetch-all", + jobId: "job-fetch-all-options", + options: { + statusFilter: "Draft published", + includeRemoved: true, + dryRun: true, + }, + onProgress: vi.fn(), + logger: createLogger(), + childEnv: process.env, + signal: new AbortController().signal, + timeoutMs: 20 * 60 * 1000, + }); + + expect(result.success).toBe(true); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([ + "--status-filter", + "Draft published", + "--include-removed", + ]) + ); + expect(mockSpawn.mock.calls[0]?.[1]).not.toEqual( + expect.arrayContaining([ + "--status-filter", + NOTION_PROPERTIES.READY_TO_PUBLISH, + ]) + ); + }); }); diff --git a/api-server/fetch-job-runner.ts b/api-server/fetch-job-runner.ts index b9f8caf9..da362da4 100644 --- a/api-server/fetch-job-runner.ts +++ b/api-server/fetch-job-runner.ts @@ -38,8 +38,11 @@ interface FetchJobLogger { interface FetchJobOptions { maxPages?: number; + pageId?: string; + statusFilter?: string; force?: boolean; dryRun?: boolean; + includeRemoved?: boolean; } interface FetchJobResult { @@ -59,7 +62,7 @@ interface FetchJobResult { } interface RunFetchJobInput { - type: "fetch-ready" | "fetch-all"; + type: "fetch-one" | "fetch-ready" | "fetch-all"; jobId: string; options: FetchJobOptions; onProgress: (current: number, total: number, message: string) => void; @@ -192,7 +195,7 @@ function parseCiFetchHoldMs(value: string | undefined): number { } async function runGenerationScript( - type: "fetch-ready" | "fetch-all", + type: "fetch-one" | "fetch-ready" | "fetch-all", options: FetchJobOptions, tempDir: string, childEnv: NodeJS.ProcessEnv, @@ -201,12 +204,21 @@ async function runGenerationScript( timeoutMs: number ): Promise { const args = ["scripts/notion-fetch-all"]; + if (type === "fetch-one" && options.pageId) { + args.push("--page-id", options.pageId); + } if (type === "fetch-ready") { args.push("--status-filter", NOTION_PROPERTIES.READY_TO_PUBLISH); } + if (type === "fetch-all" && options.statusFilter) { + args.push("--status-filter", options.statusFilter); + } if (options.maxPages !== undefined && options.maxPages > 0) { args.push("--max-pages", String(options.maxPages)); } + if (options.includeRemoved) { + args.push("--include-removed"); + } if (options.force) { args.push("--force"); } @@ -350,7 +362,7 @@ async function runGenerationScript( function parseTerminalSummary( output: string, - type: "fetch-ready" | "fetch-all" + type: "fetch-one" | "fetch-ready" | "fetch-all" ): { pagesProcessed: number; candidateIds: string[] } { const parsedOutput = extractLastJsonLine(output) as { candidateIds?: unknown; diff --git a/api-server/github-status-callback-flow.test.ts b/api-server/github-status-callback-flow.test.ts index ff63749e..8530f51e 100644 --- a/api-server/github-status-callback-flow.test.ts +++ b/api-server/github-status-callback-flow.test.ts @@ -47,7 +47,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => describe("Idempotency - Race Conditions", () => { it("should handle concurrent status reporting attempts safely", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let apiCallCount = 0; mockFetch.mockImplementation(async () => { @@ -62,7 +62,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => // Simulate concurrent completion callbacks const completionPromises = Array.from({ length: 5 }, () => - reportJobCompletion(validGitHubContext, true, "notion:fetch", { + reportJobCompletion(validGitHubContext, true, "fetch-one", { duration: 100, }) ); @@ -80,7 +80,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should handle check-then-act race condition in job executor", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -103,7 +103,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result1 = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); expect(result1).not.toBeNull(); @@ -118,7 +118,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should handle rapid successive status updates", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -133,7 +133,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const promises = []; for (let i = 0; i < 10; i++) { promises.push( - reportJobCompletion(validGitHubContext, true, "notion:fetch", { + reportJobCompletion(validGitHubContext, true, "fetch-one", { duration: 100, }) ); @@ -157,7 +157,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => .mockImplementation(() => {}); const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -174,7 +174,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null after retries are exhausted @@ -205,7 +205,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null without retrying @@ -241,7 +241,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const reportPromise = reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Fast forward through retries @@ -269,7 +269,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null without crashing @@ -284,14 +284,14 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should survive server restart during status reporting", async () => { // Create job and mark as reported const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); mockFetch.mockResolvedValue({ ok: true, json: async () => ({ id: 1, state: "success" }), }); - await reportJobCompletion(validGitHubContext, true, "notion:fetch"); + await reportJobCompletion(validGitHubContext, true, "fetch-one"); tracker.markGitHubStatusReported(jobId); expect(tracker.isGitHubStatusReported(jobId)).toBe(true); @@ -312,7 +312,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should allow retry after server restart if status not reported", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); // Simulate failed status report mockFetch.mockResolvedValue({ @@ -325,7 +325,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => .spyOn(console, "error") .mockImplementation(() => {}); - await reportJobCompletion(validGitHubContext, true, "notion:fetch"); + await reportJobCompletion(validGitHubContext, true, "fetch-one"); // Flag should be false expect(tracker.isGitHubStatusReported(jobId)).toBe(false); @@ -349,7 +349,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); expect(result).not.toBeNull(); @@ -361,7 +361,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => describe("Clear and Retry Mechanism", () => { it("should allow manual retry via clearGitHubStatusReported", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); // First attempt fails mockFetch.mockResolvedValue({ @@ -377,7 +377,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result1 = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); expect(result1).toBeNull(); expect(tracker.isGitHubStatusReported(jobId)).toBe(false); @@ -395,7 +395,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result2 = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); expect(result2).not.toBeNull(); @@ -413,7 +413,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should persist cleared flag across server restart", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); tracker.markGitHubStatusReported(jobId); expect(tracker.isGitHubStatusReported(jobId)).toBe(true); @@ -432,7 +432,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => describe("Edge Cases", () => { it("should handle job completion without GitHub context", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); // No GitHub context + const jobId = tracker.createJob("fetch-one"); // No GitHub context mockFetch.mockResolvedValue({ ok: true, @@ -458,7 +458,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should handle gracefully @@ -519,7 +519,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const reportPromise = reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Fast forward through retries with exponential backoff @@ -552,7 +552,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const reportPromise = reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Fast forward through all retries @@ -573,7 +573,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => describe("Status Update Race Conditions", () => { it("should not report status twice for same job completion", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -590,7 +590,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result1 = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); if (result1 !== null) { tracker.markGitHubStatusReported(jobId); @@ -602,7 +602,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result2 = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); if (result2 !== null) { tracker.markGitHubStatusReported(jobId); @@ -619,7 +619,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => describe("Double-Checked Locking Pattern", () => { it("should implement double-checked locking for idempotency", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -641,7 +641,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); if (result !== null) { tracker.markGitHubStatusReported(jobId); @@ -655,7 +655,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => it("should handle race condition between check and mark", async () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch", validGitHubContext); + const jobId = tracker.createJob("fetch-one", validGitHubContext); let callCount = 0; mockFetch.mockImplementation(async () => { @@ -674,7 +674,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); if (result !== null) { tracker.markGitHubStatusReported(jobId); @@ -689,7 +689,7 @@ describe("GitHub Status Callback Flow - Idempotency and Failure Handling", () => const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); if (result !== null) { tracker.markGitHubStatusReported(jobId); diff --git a/api-server/github-status-idempotency.test.ts b/api-server/github-status-idempotency.test.ts index fa442c07..006495d3 100644 --- a/api-server/github-status-idempotency.test.ts +++ b/api-server/github-status-idempotency.test.ts @@ -82,10 +82,10 @@ describe("GitHub Status - Idempotency and Integration", () => { }); // Report the same job completion twice - function itself is not idempotent - await reportJobCompletion(validGitHubContext, true, "notion:fetch", { + await reportJobCompletion(validGitHubContext, true, "fetch-one", { duration: 1000, }); - await reportJobCompletion(validGitHubContext, true, "notion:fetch", { + await reportJobCompletion(validGitHubContext, true, "fetch-one", { duration: 1000, }); @@ -99,7 +99,7 @@ describe("GitHub Status - Idempotency and Integration", () => { json: async () => ({ id: 1, state: "success" }), }); - await reportJobCompletion(validGitHubContext, true, "notion:fetch"); + await reportJobCompletion(validGitHubContext, true, "fetch-one"); await reportJobCompletion(validGitHubContext, true, "notion:translate"); // Different job types should result in different status updates @@ -108,7 +108,7 @@ describe("GitHub Status - Idempotency and Integration", () => { // Verify the contexts differ const firstCall = JSON.parse(mockFetch.mock.calls[0][1]?.body as string); const secondCall = JSON.parse(mockFetch.mock.calls[1][1]?.body as string); - expect(firstCall.description).toContain("notion:fetch"); + expect(firstCall.description).toContain("fetch-one"); expect(secondCall.description).toContain("notion:translate"); }); }); @@ -290,11 +290,11 @@ describe("GitHub Status - Idempotency and Integration", () => { json: async () => ({ id: 1, state: "success" }), }); - await reportJobCompletion(validGitHubContext, true, "notion:fetch-all"); + await reportJobCompletion(validGitHubContext, true, "fetch-all"); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1]?.body as string); - expect(body.description).toContain("notion:fetch-all"); + expect(body.description).toContain("fetch-all"); }); it("should include duration in status description", async () => { @@ -303,7 +303,7 @@ describe("GitHub Status - Idempotency and Integration", () => { json: async () => ({ id: 1, state: "success" }), }); - await reportJobCompletion(validGitHubContext, true, "notion:fetch", { + await reportJobCompletion(validGitHubContext, true, "fetch-one", { duration: 1234, }); @@ -318,7 +318,7 @@ describe("GitHub Status - Idempotency and Integration", () => { json: async () => ({ id: 1, state: "failure" }), }); - await reportJobCompletion(validGitHubContext, false, "notion:fetch", { + await reportJobCompletion(validGitHubContext, false, "fetch-one", { error: "Connection timeout", }); @@ -334,7 +334,7 @@ describe("GitHub Status - Idempotency and Integration", () => { }); const longError = "x".repeat(200); - await reportJobCompletion(validGitHubContext, false, "notion:fetch", { + await reportJobCompletion(validGitHubContext, false, "fetch-one", { error: longError, }); @@ -355,7 +355,7 @@ describe("GitHub Status - Idempotency and Integration", () => { const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null and not throw @@ -372,7 +372,7 @@ describe("GitHub Status - Idempotency and Integration", () => { const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null and not throw @@ -385,7 +385,7 @@ describe("GitHub Status - Idempotency and Integration", () => { const result = await reportJobCompletion( validGitHubContext, true, - "notion:fetch" + "fetch-one" ); // Should return null and not throw @@ -420,7 +420,7 @@ describe("GitHub Status - Idempotency and Integration", () => { await reportJobCompletion( { ...validGitHubContext, targetUrl: "https://example.com/job/123" }, true, - "notion:fetch" + "fetch-one" ); const callArgs = mockFetch.mock.calls[0]; diff --git a/api-server/github-status.test.ts b/api-server/github-status.test.ts index beb6451a..67436170 100644 --- a/api-server/github-status.test.ts +++ b/api-server/github-status.test.ts @@ -346,11 +346,7 @@ describe("github-status", () => { json: async () => ({ id: 1, state: "success" }), }); - const result = await reportJobCompletion( - validOptions, - true, - "notion:fetch" - ); + const result = await reportJobCompletion(validOptions, true, "fetch-one"); expect(result).toBeDefined(); expect(result?.state).toBe("success"); @@ -365,7 +361,7 @@ describe("github-status", () => { const result = await reportJobCompletion( validOptions, false, - "notion:fetch" + "fetch-one" ); expect(result).toBeDefined(); @@ -378,7 +374,7 @@ describe("github-status", () => { json: async () => ({ id: 3, state: "success" }), }); - await reportJobCompletion(validOptions, true, "notion:fetch", { + await reportJobCompletion(validOptions, true, "fetch-one", { duration: 1500, }); @@ -393,7 +389,7 @@ describe("github-status", () => { json: async () => ({ id: 4, state: "failure" }), }); - await reportJobCompletion(validOptions, false, "notion:fetch", { + await reportJobCompletion(validOptions, false, "fetch-one", { error: "Connection failed", }); @@ -414,11 +410,7 @@ describe("github-status", () => { .spyOn(console, "error") .mockImplementation(() => {}); - const result = await reportJobCompletion( - validOptions, - true, - "notion:fetch" - ); + const result = await reportJobCompletion(validOptions, true, "fetch-one"); expect(result).toBeNull(); expect(consoleErrorSpy).toHaveBeenCalled(); @@ -432,11 +424,7 @@ describe("github-status", () => { .spyOn(console, "error") .mockImplementation(() => {}); - const result = await reportJobCompletion( - validOptions, - true, - "notion:fetch" - ); + const result = await reportJobCompletion(validOptions, true, "fetch-one"); expect(result).toBeNull(); expect(consoleErrorSpy).toHaveBeenCalled(); diff --git a/api-server/handler-integration.test.ts b/api-server/handler-integration.test.ts index 20b54350..0dcd83ca 100644 --- a/api-server/handler-integration.test.ts +++ b/api-server/handler-integration.test.ts @@ -56,12 +56,12 @@ describe("API Handler Integration Tests", () => { const tracker = getJobTracker(); // Create job - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); expect(jobId).toBeTruthy(); let job = tracker.getJob(jobId); expect(job?.status).toBe("pending"); - expect(job?.type).toBe("notion:fetch"); + expect(job?.type).toBe("fetch-one"); expect(job?.createdAt).toBeInstanceOf(Date); // Start job @@ -89,7 +89,7 @@ describe("API Handler Integration Tests", () => { it("should handle job failure workflow", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); // Start and fail job tracker.updateJobStatus(jobId, "running"); @@ -109,7 +109,7 @@ describe("API Handler Integration Tests", () => { // Create multiple jobs const jobIds = Array.from({ length: 10 }, () => - tracker.createJob("notion:fetch") + tracker.createJob("fetch-one") ); // Update all to running @@ -144,9 +144,9 @@ describe("API Handler Integration Tests", () => { // Create test jobs with different types and statuses const jobs = [ - { type: "notion:fetch" as JobType, status: "pending" }, - { type: "notion:fetch" as JobType, status: "running" }, - { type: "notion:fetch-all" as JobType, status: "completed" }, + { type: "fetch-one" as JobType, status: "pending" }, + { type: "fetch-one" as JobType, status: "running" }, + { type: "fetch-all" as JobType, status: "completed" }, { type: "notion:translate" as JobType, status: "failed" }, { type: "notion:status-translation" as JobType, status: "pending" }, ]; @@ -179,8 +179,8 @@ describe("API Handler Integration Tests", () => { it("should filter jobs by type", () => { const tracker = getJobTracker(); - const fetchJobs = tracker.getJobsByType("notion:fetch"); - const fetchAllJobs = tracker.getJobsByType("notion:fetch-all"); + const fetchJobs = tracker.getJobsByType("fetch-one"); + const fetchAllJobs = tracker.getJobsByType("fetch-all"); const translateJobs = tracker.getJobsByType("notion:translate"); expect(fetchJobs).toHaveLength(2); @@ -192,7 +192,7 @@ describe("API Handler Integration Tests", () => { const tracker = getJobTracker(); // Get all fetch jobs - const fetchJobs = tracker.getJobsByType("notion:fetch"); + const fetchJobs = tracker.getJobsByType("fetch-one"); // Filter to pending only const pendingFetch = fetchJobs.filter((j) => j.status === "pending"); @@ -207,8 +207,8 @@ describe("API Handler Integration Tests", () => { it("should delete jobs and update tracker state", () => { const tracker = getJobTracker(); - const jobId1 = tracker.createJob("notion:fetch"); - const jobId2 = tracker.createJob("notion:fetch-all"); + const jobId1 = tracker.createJob("fetch-one"); + const jobId2 = tracker.createJob("fetch-all"); expect(tracker.getAllJobs()).toHaveLength(2); @@ -391,7 +391,7 @@ describe("API Handler Integration Tests", () => { it("should handle invalid status transitions gracefully", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); // Try to set invalid status - the function accepts it but job status // should remain one of the valid values diff --git a/api-server/http-integration.test.ts b/api-server/http-integration.test.ts index a17e980e..6108e6a7 100644 --- a/api-server/http-integration.test.ts +++ b/api-server/http-integration.test.ts @@ -94,8 +94,8 @@ describe("HTTP Integration Tests", () => { expect(res.status).toBe(200); const body = await res.json(); const typeIds = body.data.types.map((t: { id: string }) => t.id); - expect(typeIds).toContain("notion:fetch"); - expect(typeIds).toContain("notion:fetch-all"); + expect(typeIds).toContain("fetch-one"); + expect(typeIds).toContain("fetch-all"); expect(typeIds).toContain("notion:count-pages"); expect(typeIds).toContain("notion:translate"); }); @@ -204,7 +204,7 @@ describe("HTTP Integration Tests", () => { it("should reject missing Content-Type", async () => { const res = await fetch(`${BASE_URL}/jobs`, { method: "POST", - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); expect(res.status).toBe(400); }); @@ -224,7 +224,7 @@ describe("HTTP Integration Tests", () => { const res = await fetch(`${BASE_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); expect(res.status).toBe(201); const body = await res.json(); @@ -238,7 +238,7 @@ describe("HTTP Integration Tests", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - type: "notion:fetch", + type: "fetch-one", options: { unknownKey: true }, }), }); @@ -268,7 +268,7 @@ describe("HTTP Integration Tests", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - type: "notion:fetch", + type: "fetch-one", options: { maxPages: 5, force: true }, }), }); @@ -294,7 +294,7 @@ describe("HTTP Integration Tests", () => { const createRes = await fetch(`${BASE_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); const createBody = await createRes.json(); const jobId = createBody.data.jobId; @@ -317,14 +317,14 @@ describe("HTTP Integration Tests", () => { await fetch(`${BASE_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); - const res = await fetch(`${BASE_URL}/jobs?type=notion:fetch`); + const res = await fetch(`${BASE_URL}/jobs?type=fetch-one`); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.items.length).toBeGreaterThanOrEqual(1); - expect(body.data.items[0].type).toBe("notion:fetch"); + expect(body.data.items[0].type).toBe("fetch-one"); }); it("should reject invalid status filter", async () => { @@ -361,7 +361,7 @@ describe("HTTP Integration Tests", () => { const createRes = await fetch(`${BASE_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); const createBody = await createRes.json(); const jobId = createBody.data.jobId; @@ -370,7 +370,7 @@ describe("HTTP Integration Tests", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.id).toBe(jobId); - expect(body.data.type).toBe("notion:fetch"); + expect(body.data.type).toBe("fetch-one"); }); }); @@ -389,7 +389,7 @@ describe("HTTP Integration Tests", () => { const createRes = await fetch(`${BASE_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "notion:fetch" }), + body: JSON.stringify({ type: "fetch-one" }), }); const createBody = await createRes.json(); const jobId = createBody.data.jobId; @@ -405,7 +405,7 @@ describe("HTTP Integration Tests", () => { it("should reject canceling a completed job", async () => { // Create and manually complete a job const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); tracker.updateJobStatus(jobId, "completed", { success: true, data: {}, diff --git a/api-server/index.test.ts b/api-server/index.test.ts index e0885613..e5e53ce8 100644 --- a/api-server/index.test.ts +++ b/api-server/index.test.ts @@ -61,11 +61,11 @@ describe("API Server - Unit Tests", () => { describe("Job Type Validation", () => { it("should accept valid job types", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); const job = tracker.getJob(jobId); expect(job).toBeDefined(); - expect(job?.type).toBe("notion:fetch"); + expect(job?.type).toBe("fetch-one"); }); it("should reject invalid job types", () => { @@ -79,7 +79,7 @@ describe("API Server - Unit Tests", () => { describe("Job Creation Flow", () => { it("should create job with pending status", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); const job = tracker.getJob(jobId); expect(job?.status).toBe("pending"); @@ -89,7 +89,7 @@ describe("API Server - Unit Tests", () => { it("should transition job from pending to running", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobStatus(jobId, "running"); @@ -118,7 +118,7 @@ describe("API Server - Unit Tests", () => { describe("Job Progress Tracking", () => { it("should track job progress", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobProgress(jobId, 5, 10, "Processing page 5"); tracker.updateJobProgress(jobId, 7, 10, "Processing page 7"); @@ -133,7 +133,7 @@ describe("API Server - Unit Tests", () => { it("should calculate completion percentage", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobProgress(jobId, 5, 10, "Halfway there"); @@ -147,8 +147,8 @@ describe("API Server - Unit Tests", () => { describe("Job Filtering", () => { beforeEach(() => { const tracker = getJobTracker(); - const job1 = tracker.createJob("notion:fetch"); - const job2 = tracker.createJob("notion:fetch-all"); + const job1 = tracker.createJob("fetch-one"); + const job2 = tracker.createJob("fetch-all"); const job3 = tracker.createJob("notion:translate"); tracker.updateJobStatus(job1, "running"); @@ -171,8 +171,8 @@ describe("API Server - Unit Tests", () => { it("should filter jobs by type", () => { const tracker = getJobTracker(); - const fetchJobs = tracker.getJobsByType("notion:fetch"); - const fetchAllJobs = tracker.getJobsByType("notion:fetch-all"); + const fetchJobs = tracker.getJobsByType("fetch-one"); + const fetchAllJobs = tracker.getJobsByType("fetch-all"); expect(fetchJobs).toHaveLength(1); expect(fetchAllJobs).toHaveLength(1); @@ -182,7 +182,7 @@ describe("API Server - Unit Tests", () => { describe("Job Deletion", () => { it("should delete a job", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); expect(tracker.getJob(jobId)).toBeDefined(); @@ -203,8 +203,8 @@ describe("API Server - Unit Tests", () => { describe("Job Listing", () => { it("should return all jobs", () => { const tracker = getJobTracker(); - tracker.createJob("notion:fetch"); - tracker.createJob("notion:fetch-all"); + tracker.createJob("fetch-one"); + tracker.createJob("fetch-all"); tracker.createJob("notion:translate"); const jobs = tracker.getAllJobs(); @@ -223,7 +223,7 @@ describe("API Server - Unit Tests", () => { describe("Job Serialization", () => { it("should preserve job data through serialization", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); tracker.updateJobStatus(jobId, "running"); tracker.updateJobProgress(jobId, 5, 10, "Processing"); @@ -232,7 +232,7 @@ describe("API Server - Unit Tests", () => { const serialized = JSON.parse(JSON.stringify(job)); expect(serialized.id).toBe(jobId); - expect(serialized.type).toBe("notion:fetch"); + expect(serialized.type).toBe("fetch-one"); expect(serialized.status).toBe("running"); expect(serialized.progress).toEqual({ current: 5, @@ -280,7 +280,7 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create job - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); let job = tracker.getJob(jobId); expect(job?.status).toBe("pending"); @@ -310,7 +310,7 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create job - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); // Start job tracker.updateJobStatus(jobId, "running"); @@ -331,8 +331,8 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); const jobIds = [ - tracker.createJob("notion:fetch"), - tracker.createJob("notion:fetch-all"), + tracker.createJob("fetch-one"), + tracker.createJob("fetch-all"), tracker.createJob("notion:translate"), ]; @@ -367,7 +367,7 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create job - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); expect(tracker.getJob(jobId)?.status).toBe("pending"); // Cancel job @@ -385,7 +385,7 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create and start job - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobStatus(jobId, "running"); expect(tracker.getJob(jobId)?.status).toBe("running"); @@ -404,8 +404,8 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create multiple jobs with different statuses - const job1 = tracker.createJob("notion:fetch"); - const job2 = tracker.createJob("notion:fetch-all"); + const job1 = tracker.createJob("fetch-one"); + const job2 = tracker.createJob("fetch-all"); const job3 = tracker.createJob("notion:translate"); tracker.updateJobStatus(job1, "running"); @@ -432,17 +432,17 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create multiple jobs with different types - const job1 = tracker.createJob("notion:fetch"); - const job2 = tracker.createJob("notion:fetch-all"); - const job3 = tracker.createJob("notion:fetch"); + const job1 = tracker.createJob("fetch-one"); + const job2 = tracker.createJob("fetch-all"); + const job3 = tracker.createJob("fetch-one"); // Filter by type let jobs = tracker.getAllJobs(); - jobs = jobs.filter((job) => job.type === "notion:fetch"); + jobs = jobs.filter((job) => job.type === "fetch-one"); expect(jobs).toHaveLength(2); jobs = tracker.getAllJobs(); - jobs = jobs.filter((job) => job.type === "notion:fetch-all"); + jobs = jobs.filter((job) => job.type === "fetch-all"); expect(jobs).toHaveLength(1); expect(jobs[0].id).toBe(job2); }); @@ -451,9 +451,9 @@ describe("Job Lifecycle Integration", () => { const tracker = getJobTracker(); // Create multiple jobs - const job1 = tracker.createJob("notion:fetch"); - const job2 = tracker.createJob("notion:fetch"); - const job3 = tracker.createJob("notion:fetch-all"); + const job1 = tracker.createJob("fetch-one"); + const job2 = tracker.createJob("fetch-one"); + const job3 = tracker.createJob("fetch-all"); tracker.updateJobStatus(job1, "running"); tracker.updateJobStatus(job2, "completed"); @@ -461,14 +461,14 @@ describe("Job Lifecycle Integration", () => { // Filter by status AND type let jobs = tracker.getAllJobs(); jobs = jobs.filter( - (job) => job.status === "running" && job.type === "notion:fetch" + (job) => job.status === "running" && job.type === "fetch-one" ); expect(jobs).toHaveLength(1); expect(jobs[0].id).toBe(job1); jobs = tracker.getAllJobs(); jobs = jobs.filter( - (job) => job.status === "completed" && job.type === "notion:fetch" + (job) => job.status === "completed" && job.type === "fetch-one" ); expect(jobs).toHaveLength(1); expect(jobs[0].id).toBe(job2); diff --git a/api-server/input-validation.test.ts b/api-server/input-validation.test.ts index 26fecddc..2b093b86 100644 --- a/api-server/input-validation.test.ts +++ b/api-server/input-validation.test.ts @@ -31,8 +31,8 @@ function cleanupTestData(): void { describe("Input Validation - Job Type Validation", () => { it("should accept valid job types", () => { - expect(isValidJobType("notion:fetch")).toBe(true); - expect(isValidJobType("notion:fetch-all")).toBe(true); + expect(isValidJobType("fetch-one")).toBe(true); + expect(isValidJobType("fetch-all")).toBe(true); expect(isValidJobType("notion:translate")).toBe(true); }); @@ -40,7 +40,7 @@ describe("Input Validation - Job Type Validation", () => { expect(isValidJobType("invalid:type")).toBe(false); expect(isValidJobType("notion:invalid")).toBe(false); expect(isValidJobType("")).toBe(false); - expect(isValidJobType("notion:fetch-all-extra")).toBe(false); + expect(isValidJobType("fetch-all-extra")).toBe(false); }); }); @@ -98,7 +98,7 @@ describe("Input Validation - POST /jobs Request Body", () => { }); it("should validate job type", () => { - expect(isValidJobType("notion:fetch")).toBe(true); + expect(isValidJobType("fetch-one")).toBe(true); expect(isValidJobType("invalid:type")).toBe(false); }); }); @@ -183,7 +183,7 @@ describe("Input Validation - GET /jobs Query Parameters", () => { }); it("should validate type parameter", () => { - expect(isValidJobType("notion:fetch")).toBe(true); + expect(isValidJobType("fetch-one")).toBe(true); expect(isValidJobType("invalid:type")).toBe(false); }); }); @@ -229,7 +229,7 @@ describe("Integration - Job Tracker with Validation", () => { it("should create job with valid type", () => { const tracker = getJobTracker(); - const validType = "notion:fetch"; + const validType = "fetch-one"; expect(isValidJobType(validType)).toBe(true); @@ -244,8 +244,8 @@ describe("Integration - Job Tracker with Validation", () => { const tracker = getJobTracker(); // Create jobs with different statuses - const job1 = tracker.createJob("notion:fetch"); - const job2 = tracker.createJob("notion:fetch-all"); + const job1 = tracker.createJob("fetch-one"); + const job2 = tracker.createJob("fetch-all"); const job3 = tracker.createJob("notion:translate"); tracker.updateJobStatus(job1, "running"); @@ -262,7 +262,7 @@ describe("Integration - Job Tracker with Validation", () => { expect(jobs[0].id).toBe(job1); // Test filtering by valid type - const typeFilter = "notion:fetch"; + const typeFilter = "fetch-one"; expect(isValidJobType(typeFilter)).toBe(true); jobs = tracker.getAllJobs(); @@ -277,7 +277,7 @@ describe("Integration - Job Tracker with Validation", () => { it("should validate job ID for status queries", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); // Valid job ID expect(isValidJobId(jobId)).toBe(true); @@ -340,7 +340,7 @@ describe("Endpoint Input Schemas - Complete Coverage", () => { it("should validate all required fields", () => { // Valid request body const validBody = { - type: "notion:fetch", + type: "fetch-one", options: { maxPages: 10, statusFilter: "In Progress", @@ -407,14 +407,14 @@ describe("Endpoint Input Schemas - Complete Coverage", () => { { status: "running" }, { status: "completed" }, { status: "failed" }, - { type: "notion:fetch" }, - { type: "notion:fetch-all" }, + { type: "fetch-one" }, + { type: "fetch-all" }, { type: "notion:translate" }, { type: "notion:status-translation" }, { type: "notion:status-draft" }, { type: "notion:status-publish" }, { type: "notion:status-publish-production" }, - { status: "pending", type: "notion:fetch" }, + { status: "pending", type: "fetch-one" }, ]; for (const params of validParams) { @@ -518,15 +518,15 @@ describe("Error Responses - Complete Coverage", () => { const errorResponse = { code: "INVALID_ENUM_VALUE", message: - "Invalid job type: 'invalid:type'. Valid types are: notion:fetch, notion:fetch-all, notion:translate, notion:status-translation, notion:status-draft, notion:status-publish, notion:status-publish-production", + "Invalid job type: 'invalid:type'. Valid types are: fetch-one, fetch-all, notion:translate, notion:status-translation, notion:status-draft, notion:status-publish, notion:status-publish-production", status: 400, requestId: "req_test_789", timestamp: new Date().toISOString(), details: { providedType: "invalid:type", validTypes: [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:translate", "notion:status-translation", "notion:status-draft", diff --git a/api-server/job-executor-core.test.ts b/api-server/job-executor-core.test.ts index eddee893..e0bc20e5 100644 --- a/api-server/job-executor-core.test.ts +++ b/api-server/job-executor-core.test.ts @@ -4,7 +4,7 @@ * Focused unit tests for core job execution logic including: * - parseProgressFromOutput function * - JOB_COMMANDS mapping - * - buildArgs function for notion:fetch-all + * - buildArgs function for fetch-all */ import { describe, it, expect, beforeEach } from "vitest"; @@ -161,8 +161,8 @@ describe("Core Job Logic - JOB_COMMANDS mapping", () => { describe("job type configuration", () => { it("should have entries for all job types", () => { const jobTypes: JobType[] = [ - "notion:fetch", - "notion:fetch-all", + "fetch-one", + "fetch-all", "notion:count-pages", "notion:translate", "notion:status-translation", @@ -183,12 +183,12 @@ describe("Core Job Logic - JOB_COMMANDS mapping", () => { } }); - it("should configure notion:fetch with correct script and args", () => { - const config = JOB_COMMANDS["notion:fetch"]; + it("should configure fetch-one with correct script and args", () => { + const config = JOB_COMMANDS["fetch-one"]; expect(config.script).toBe("bun"); - expect(config.args).toEqual(["scripts/notion-fetch/index.ts"]); - expect(config.buildArgs).toBeUndefined(); + expect(config.args).toEqual(["scripts/notion-fetch-all"]); + expect(config.buildArgs).toBeDefined(); }); it("should configure notion:translate with correct script and args", () => { @@ -236,8 +236,8 @@ describe("Core Job Logic - JOB_COMMANDS mapping", () => { }); }); - describe("notion:fetch-all buildArgs function", () => { - const buildArgs = JOB_COMMANDS["notion:fetch-all"].buildArgs!; + describe("fetch-all buildArgs function", () => { + const buildArgs = JOB_COMMANDS["fetch-all"].buildArgs!; it("should return empty array when no options provided", () => { const args = buildArgs({}); @@ -378,10 +378,9 @@ describe("Core Job Logic - JOB_COMMANDS mapping", () => { }); describe("edge cases", () => { - it("should treat zero maxPages as falsy and not add argument", () => { + it("should include zero maxPages when provided", () => { const args = buildArgs({ maxPages: 0 }); - // 0 is falsy in JavaScript, so the condition `if (options.maxPages)` is false - expect(args).toEqual([]); + expect(args).toEqual(["--max-pages", "0"]); }); it("should handle very large maxPages", () => { diff --git a/api-server/job-executor-timeout.test.ts b/api-server/job-executor-timeout.test.ts index 7888bc19..c8e15285 100644 --- a/api-server/job-executor-timeout.test.ts +++ b/api-server/job-executor-timeout.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { ChildProcess } from "node:child_process"; +import * as fetchRunner from "./fetch-job-runner"; // Import the functions we need to test import { @@ -24,8 +25,8 @@ vi.mock("node:child_process", () => ({ // Mock content-repo integration to keep timeout tests focused on process lifecycle vi.mock("./content-repo", () => ({ isContentMutatingJob: (jobType: string) => - jobType === "notion:fetch" || - jobType === "notion:fetch-all" || + jobType === "fetch-one" || + jobType === "fetch-all" || jobType === "notion:translate", runContentTask: async ( _taskName: string, @@ -184,12 +185,12 @@ describe("job-executor - timeout behavior", () => { }); describe("timeout configuration", () => { - it("should use job-specific timeout for notion:fetch", () => { - expect(JOB_COMMANDS["notion:fetch"].timeoutMs).toBe(5 * 60 * 1000); // 5 minutes + it("should use job-specific timeout for fetch-one", () => { + expect(JOB_COMMANDS["fetch-one"].timeoutMs).toBe(60 * 60 * 1000); // 60 minutes }); - it("should use longer timeout for notion:fetch-all", () => { - expect(JOB_COMMANDS["notion:fetch-all"].timeoutMs).toBe(60 * 60 * 1000); // 60 minutes + it("should use longer timeout for fetch-all", () => { + expect(JOB_COMMANDS["fetch-all"].timeoutMs).toBe(60 * 60 * 1000); // 60 minutes }); it("should use medium timeout for notion:translate", () => { @@ -567,23 +568,29 @@ describe("job-executor - timeout behavior", () => { }); describe("different job type timeouts", () => { - it("should use longer timeout for notion:fetch-all jobs", async () => { + it("should use longer timeout for fetch-all jobs", async () => { const tracker = getJobTracker(); - const mockChild = createMockChildProcess(); - - mockSpawn.mockReturnValue(mockChild.process); + vi.spyOn(fetchRunner, "runFetchJob").mockResolvedValue({ + success: true, + output: "", + terminal: { + pagesProcessed: 0, + pagesSkipped: 0, + commitHash: null, + }, + }); // Don't set JOB_TIMEOUT_MS - should use job-specific timeout - const jobId = tracker.createJob("notion:fetch-all"); - executeJobAsync("notion:fetch-all", jobId, {}); + const jobId = tracker.createJob("fetch-all"); + executeJobAsync("fetch-all", jobId, {}); await vi.waitFor(() => { - expect(mockSpawn).toHaveBeenCalled(); + expect(fetchRunner.runFetchJob).toHaveBeenCalled(); }); // The default timeout for fetch-all is 60 minutes (3600000ms) // Verify it was configured correctly (we can't wait that long in a test) - expect(JOB_COMMANDS["notion:fetch-all"].timeoutMs).toBe(60 * 60 * 1000); + expect(JOB_COMMANDS["fetch-all"].timeoutMs).toBe(60 * 60 * 1000); }); it("should use shorter timeout for notion:status-draft jobs", async () => { diff --git a/api-server/job-executor.ts b/api-server/job-executor.ts index 6ec838e7..b688dfd1 100644 --- a/api-server/job-executor.ts +++ b/api-server/job-executor.ts @@ -101,6 +101,7 @@ export interface JobExecutionContext { export interface JobOptions { maxPages?: number; + pageId?: string; statusFilter?: string; force?: boolean; dryRun?: boolean; @@ -191,8 +192,12 @@ function isJobCancelled(jobId: string): boolean { function isFetchJobType( jobType: JobType -): jobType is "fetch-ready" | "fetch-all" { - return jobType === "fetch-ready" || jobType === "fetch-all"; +): jobType is "fetch-one" | "fetch-ready" | "fetch-all" { + return ( + jobType === "fetch-one" || + jobType === "fetch-ready" || + jobType === "fetch-all" + ); } export const JOB_COMMANDS: Record< @@ -204,6 +209,20 @@ export const JOB_COMMANDS: Record< timeoutMs: number; } > = { + "fetch-one": { + script: "bun", + args: ["scripts/notion-fetch-all"], + buildArgs: (options) => { + const args: string[] = []; + if (options.pageId) { + args.push("--page-id", options.pageId); + } + if (options.force) args.push("--force"); + if (options.dryRun) args.push("--dry-run"); + return args; + }, + timeoutMs: DEFAULT_FETCH_JOB_TIMEOUT_MS, + }, "fetch-ready": { script: "bun", args: ["scripts/notion-fetch-all", "--status-filter", "Ready to publish"], @@ -226,8 +245,12 @@ export const JOB_COMMANDS: Record< if (options.maxPages !== undefined) { args.push("--max-pages", String(options.maxPages)); } + if (options.statusFilter) { + args.push("--status-filter", options.statusFilter); + } if (options.force) args.push("--force"); if (options.dryRun) args.push("--dry-run"); + if (options.includeRemoved) args.push("--include-removed"); return args; }, timeoutMs: DEFAULT_FETCH_JOB_TIMEOUT_MS, @@ -242,9 +265,9 @@ export const JOB_COMMANDS: Record< args: ["scripts/notion-fetch-all"], buildArgs: (options) => { const args: string[] = []; - if (options.maxPages) args.push(`--max-pages`, String(options.maxPages)); + if (options.maxPages) args.push("--max-pages", String(options.maxPages)); if (options.statusFilter) - args.push(`--status-filter`, options.statusFilter); + args.push("--status-filter", options.statusFilter); if (options.force) args.push("--force"); if (options.dryRun) args.push("--dry-run"); if (options.includeRemoved) args.push("--include-removed"); diff --git a/api-server/job-persistence-deterministic.test.ts b/api-server/job-persistence-deterministic.test.ts index 62dd55f2..32d107e1 100644 --- a/api-server/job-persistence-deterministic.test.ts +++ b/api-server/job-persistence-deterministic.test.ts @@ -79,7 +79,7 @@ describe("job-persistence - deterministic behavior", () => { it("should produce identical output for identical save/load cycles", async () => { const job: PersistedJob = { id: "deterministic-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: "2024-01-01T00:00:00.000Z", progress: { current: 5, total: 10, message: "Processing" }, @@ -102,19 +102,19 @@ describe("job-persistence - deterministic behavior", () => { const jobs: PersistedJob[] = [ { id: "deterministic-job-order-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: "2024-01-01T00:00:00.000Z", }, { id: "deterministic-job-order-2", - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: "2024-01-01T01:00:00.000Z", }, { id: "deterministic-job-order-3", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: "2024-01-01T02:00:00.000Z", }, @@ -143,20 +143,20 @@ describe("job-persistence - deterministic behavior", () => { const updates: PersistedJob[] = [ { id: jobId, - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: "2024-01-01T00:00:00.000Z", }, { id: jobId, - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: "2024-01-01T00:00:00.000Z", startedAt: "2024-01-01T00:01:00.000Z", }, { id: jobId, - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: "2024-01-01T00:00:00.000Z", startedAt: "2024-01-01T00:01:00.000Z", @@ -164,7 +164,7 @@ describe("job-persistence - deterministic behavior", () => { }, { id: jobId, - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: "2024-01-01T00:00:00.000Z", startedAt: "2024-01-01T00:01:00.000Z", @@ -189,21 +189,21 @@ describe("job-persistence - deterministic behavior", () => { const jobs: PersistedJob[] = [ { id: "old-completed", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(now - 48 * 60 * 60 * 1000).toISOString(), completedAt: new Date(now - 25 * 60 * 60 * 1000).toISOString(), }, { id: "recent-completed", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(), completedAt: new Date(now - 1 * 60 * 60 * 1000).toISOString(), }, { id: "old-pending", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date(now - 48 * 60 * 60 * 1000).toISOString(), }, @@ -382,7 +382,7 @@ describe("job-persistence - recoverable behavior", () => { // Should be able to save new jobs after corruption const newJob: PersistedJob = { id: "recovery-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -394,9 +394,7 @@ describe("job-persistence - recoverable behavior", () => { it("should recover from partially written jobs file", async () => { // Create a partially written file (simulating crash during write) - createCorruptedJobsFile( - '{"jobs": [{"id": "job-1", "type": "notion:fetch"' - ); + createCorruptedJobsFile('{"jobs": [{"id": "job-1", "type": "fetch-one"'); // Should handle gracefully const jobs = await loadAllJobs(); @@ -414,7 +412,7 @@ describe("job-persistence - recoverable behavior", () => { // Should be able to create new jobs const job: PersistedJob = { id: "after-empty", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -430,11 +428,11 @@ describe("job-persistence - recoverable behavior", () => { jobs: [ { id: "valid-job", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: "2024-01-01T00:00:00.000Z", }, - { id: "invalid-job", type: "notion:fetch" }, // Missing status + { id: "invalid-job", type: "fetch-one" }, // Missing status null, // Null entry "string-entry", // Invalid type ], @@ -509,7 +507,7 @@ describe("job-persistence - recoverable behavior", () => { // Should create directory and save job const job: PersistedJob = { id: "no-dir-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -584,7 +582,7 @@ describe("job-persistence - recoverable behavior", () => { it("should handle deletion of non-existent job gracefully", async () => { const job: PersistedJob = { id: "real-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -603,7 +601,7 @@ describe("job-persistence - recoverable behavior", () => { const now = Date.now(); const oldJob: PersistedJob = { id: "old-job", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(now - 48 * 60 * 60 * 1000).toISOString(), completedAt: new Date(now - 25 * 60 * 60 * 1000).toISOString(), @@ -628,7 +626,7 @@ describe("job-persistence - recoverable behavior", () => { for (let i = 0; i < 10; i++) { const job: PersistedJob = { id: `concurrent-job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -652,7 +650,7 @@ describe("job-persistence - recoverable behavior", () => { it("should handle job with all optional fields populated", async () => { const fullJob: PersistedJob = { id: "full-job", - type: "notion:fetch-all", + type: "fetch-all", status: "completed", createdAt: "2024-01-01T00:00:00.000Z", startedAt: "2024-01-01T00:01:00.000Z", @@ -680,7 +678,7 @@ describe("job-persistence - recoverable behavior", () => { it("should handle job with minimal fields", async () => { const minimalJob: PersistedJob = { id: "minimal-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -759,7 +757,7 @@ describe("job-persistence - recoverable behavior", () => { it("should handle repeated save operations idempotently", async () => { const job: PersistedJob = { id: "idempotent-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: "2024-01-01T00:00:00.000Z", }; @@ -802,7 +800,7 @@ describe("job-persistence - recoverable behavior", () => { const now = Date.now(); const oldJob: PersistedJob = { id: "old-job", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(now - 48 * 60 * 60 * 1000).toISOString(), completedAt: new Date(now - 25 * 60 * 60 * 1000).toISOString(), diff --git a/api-server/job-persistence-race.test.ts b/api-server/job-persistence-race.test.ts index 5c3c9dcd..da452b08 100644 --- a/api-server/job-persistence-race.test.ts +++ b/api-server/job-persistence-race.test.ts @@ -30,7 +30,7 @@ describe("job-persistence race conditions", () => { for (let i = 0; i < 10; i++) { const job: PersistedJob = { id: `job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: new Date().toISOString(), startedAt: new Date().toISOString(), @@ -94,7 +94,7 @@ describe("job-persistence race conditions", () => { it("should handle rapid sequential updates to the same job", async () => { const job: PersistedJob = { id: "rapid-update-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -159,7 +159,7 @@ describe("job-persistence race conditions", () => { for (let i = 0; i < 20; i++) { const job: PersistedJob = { id: `multi-job-${i}`, - type: i % 2 === 0 ? "notion:fetch" : "notion:fetch-all", + type: i % 2 === 0 ? "fetch-one" : "fetch-all", status: "pending", createdAt: new Date().toISOString(), }; @@ -255,7 +255,7 @@ describe("job-persistence race conditions", () => { for (let i = 0; i < 10; i++) { const job: PersistedJob = { id: `existing-job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -294,7 +294,7 @@ describe("job-persistence race conditions", () => { setTimeout(() => { const newJob: PersistedJob = { id: `new-job-${i}`, - type: "notion:fetch-all", + type: "fetch-all", status: "pending", createdAt: new Date().toISOString(), }; @@ -338,7 +338,7 @@ describe("job-persistence race conditions", () => { for (let i = 0; i < jobCount; i++) { const job: PersistedJob = { id: `stress-job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -403,7 +403,7 @@ describe("job-persistence race conditions", () => { it("should use temp file and atomic rename", async () => { const job: PersistedJob = { id: "atomic-test-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; diff --git a/api-server/job-persistence.test.ts b/api-server/job-persistence.test.ts index 2c45f6b8..4256dce9 100644 --- a/api-server/job-persistence.test.ts +++ b/api-server/job-persistence.test.ts @@ -40,7 +40,7 @@ describe("job-persistence", () => { it("should save and load a job", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -55,7 +55,7 @@ describe("job-persistence", () => { it("should update an existing job", async () => { const job: PersistedJob = { id: "test-job-2", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -88,14 +88,14 @@ describe("job-persistence", () => { it("should save multiple jobs", async () => { const job1: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; const job2: PersistedJob = { id: "test-job-2", - type: "notion:fetch-all", + type: "fetch-all", status: "completed", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), @@ -123,7 +123,7 @@ describe("job-persistence", () => { it("should delete a job", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -146,14 +146,14 @@ describe("job-persistence", () => { it("should handle multiple deletes", async () => { const job1: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; const job2: PersistedJob = { id: "test-job-2", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -274,7 +274,7 @@ describe("job-persistence", () => { it("should store job result with data", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), @@ -299,7 +299,7 @@ describe("job-persistence", () => { it("should store job result with error", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "failed", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), @@ -323,7 +323,7 @@ describe("job-persistence", () => { it("should update job progress", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: new Date().toISOString(), progress: { @@ -355,7 +355,7 @@ describe("job-persistence", () => { it("should store GitHub context and status", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), github: { @@ -380,7 +380,7 @@ describe("job-persistence", () => { it("should update GitHub status reported", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), @@ -410,7 +410,7 @@ describe("job-persistence", () => { it("should not remove recently completed jobs", async () => { const job: PersistedJob = { id: "test-job-1", - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date().toISOString(), completedAt: new Date().toISOString(), @@ -430,7 +430,7 @@ describe("job-persistence", () => { const job: PersistedJob = { id: "old-pending-job", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: oldDate, }; @@ -449,7 +449,7 @@ describe("job-persistence", () => { const job: PersistedJob = { id: "old-running-job", - type: "notion:fetch", + type: "fetch-one", status: "running", createdAt: oldDate, startedAt: oldDate, @@ -469,7 +469,7 @@ describe("job-persistence", () => { const job: PersistedJob = { id: "old-failed-job", - type: "notion:fetch", + type: "fetch-one", status: "failed", createdAt: oldDate, completedAt: oldDate, @@ -493,7 +493,7 @@ describe("job-persistence", () => { for (let i = 0; i < 10; i++) { const job: PersistedJob = { id: `test-job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(Date.now() - i * 1000).toISOString(), completedAt: new Date(Date.now() - i * 1000).toISOString(), @@ -506,7 +506,7 @@ describe("job-persistence", () => { for (let i = 0; i < 5; i++) { const job: PersistedJob = { id: `pending-job-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }; @@ -532,14 +532,14 @@ describe("job-persistence", () => { // Save 2 pending jobs saveJob({ id: "pending-1", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }); saveJob({ id: "pending-2", - type: "notion:fetch", + type: "fetch-one", status: "pending", createdAt: new Date().toISOString(), }); @@ -549,7 +549,7 @@ describe("job-persistence", () => { for (let i = 0; i < 5; i++) { saveJob({ id: `completed-${i}`, - type: "notion:fetch", + type: "fetch-one", status: "completed", createdAt: new Date(Date.now() - i * 1000).toISOString(), completedAt: new Date(Date.now() - i * 1000).toISOString(), diff --git a/api-server/job-tracker.test.ts b/api-server/job-tracker.test.ts index 031d780f..4e13b394 100644 --- a/api-server/job-tracker.test.ts +++ b/api-server/job-tracker.test.ts @@ -32,7 +32,7 @@ describe("JobTracker", () => { describe("createJob", () => { it("should create a new job and return a job ID", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); expect(jobId).toBeTruthy(); expect(typeof jobId).toBe("string"); @@ -40,15 +40,15 @@ describe("JobTracker", () => { const job = tracker.getJob(jobId); expect(job).toBeDefined(); expect(job?.id).toBe(jobId); - expect(job?.type).toBe("notion:fetch"); + expect(job?.type).toBe("fetch-one"); expect(job?.status).toBe("pending"); expect(job?.createdAt).toBeInstanceOf(Date); }); it("should create unique job IDs", () => { const tracker = getJobTracker(); - const jobId1 = tracker.createJob("notion:fetch"); - const jobId2 = tracker.createJob("notion:fetch-all"); + const jobId1 = tracker.createJob("fetch-one"); + const jobId2 = tracker.createJob("fetch-all"); expect(jobId1).not.toBe(jobId2); }); @@ -75,7 +75,7 @@ describe("JobTracker", () => { describe("updateJobStatus", () => { it("should update job status to running", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); tracker.updateJobStatus(jobId, "running"); @@ -86,7 +86,7 @@ describe("JobTracker", () => { it("should update job status to completed", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); tracker.updateJobStatus(jobId, "running"); tracker.updateJobStatus(jobId, "completed", { @@ -103,7 +103,7 @@ describe("JobTracker", () => { it("should update job status to failed", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); tracker.updateJobStatus(jobId, "running"); tracker.updateJobStatus(jobId, "failed", { @@ -130,7 +130,7 @@ describe("JobTracker", () => { describe("updateJobProgress", () => { it("should update job progress", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch-all"); + const jobId = tracker.createJob("fetch-all"); tracker.updateJobProgress(jobId, 5, 10, "Processing page 5"); @@ -154,10 +154,10 @@ describe("JobTracker", () => { describe("getAllJobs", () => { it("should return all jobs sorted by creation time (newest first)", async () => { const tracker = getJobTracker(); - const jobId1 = tracker.createJob("notion:fetch"); + const jobId1 = tracker.createJob("fetch-one"); // Small delay to ensure different timestamps await new Promise((resolve) => setTimeout(resolve, 10)); - const jobId2 = tracker.createJob("notion:fetch-all"); + const jobId2 = tracker.createJob("fetch-all"); const jobs = tracker.getAllJobs(); @@ -177,25 +177,23 @@ describe("JobTracker", () => { describe("getJobsByType", () => { it("should filter jobs by type", () => { const tracker = getJobTracker(); - tracker.createJob("notion:fetch"); - tracker.createJob("notion:fetch-all"); - tracker.createJob("notion:fetch-all"); + tracker.createJob("fetch-one"); + tracker.createJob("fetch-all"); + tracker.createJob("fetch-all"); tracker.createJob("notion:translate"); - const fetchAllJobs = tracker.getJobsByType("notion:fetch-all"); + const fetchAllJobs = tracker.getJobsByType("fetch-all"); expect(fetchAllJobs).toHaveLength(2); - expect(fetchAllJobs.every((job) => job.type === "notion:fetch-all")).toBe( - true - ); + expect(fetchAllJobs.every((job) => job.type === "fetch-all")).toBe(true); }); }); describe("getJobsByStatus", () => { it("should filter jobs by status", () => { const tracker = getJobTracker(); - const jobId1 = tracker.createJob("notion:fetch"); - const jobId2 = tracker.createJob("notion:fetch-all"); + const jobId1 = tracker.createJob("fetch-one"); + const jobId2 = tracker.createJob("fetch-all"); const jobId3 = tracker.createJob("notion:translate"); tracker.updateJobStatus(jobId1, "running"); @@ -213,7 +211,7 @@ describe("JobTracker", () => { describe("deleteJob", () => { it("should delete a job", () => { const tracker = getJobTracker(); - const jobId = tracker.createJob("notion:fetch"); + const jobId = tracker.createJob("fetch-one"); expect(tracker.getJob(jobId)).toBeDefined(); @@ -234,8 +232,8 @@ describe("JobTracker", () => { describe("cleanupOldJobs", () => { it("should persist jobs across tracker instances", async () => { const tracker = getJobTracker(); - const jobId1 = tracker.createJob("notion:fetch"); - const jobId2 = tracker.createJob("notion:fetch-all"); + const jobId1 = tracker.createJob("fetch-one"); + const jobId2 = tracker.createJob("fetch-all"); // Mark jobs as completed tracker.updateJobStatus(jobId1, "completed", { success: true }); diff --git a/api-server/job-tracker.ts b/api-server/job-tracker.ts index 377c194c..00648a0a 100644 --- a/api-server/job-tracker.ts +++ b/api-server/job-tracker.ts @@ -12,6 +12,7 @@ import { import type { FetchJobError, FetchJobWarning } from "./response-schemas"; export type JobType = + | "fetch-one" | "fetch-ready" | "fetch-all" | "notion:fetch" @@ -77,8 +78,12 @@ export interface Job { function isFetchJobType( jobType: JobType -): jobType is "fetch-ready" | "fetch-all" { - return jobType === "fetch-ready" || jobType === "fetch-all"; +): jobType is "fetch-one" | "fetch-ready" | "fetch-all" { + return ( + jobType === "fetch-one" || + jobType === "fetch-ready" || + jobType === "fetch-all" + ); } function createLostJobTerminal(type: JobType): Job["terminal"] { diff --git a/api-server/routes/job-types.ts b/api-server/routes/job-types.ts index c3eaa36b..0bb890e0 100644 --- a/api-server/routes/job-types.ts +++ b/api-server/routes/job-types.ts @@ -17,6 +17,7 @@ interface JobTypesData { // Job type descriptions (derived from VALID_JOB_TYPES single source of truth) const JOB_TYPE_DESCRIPTIONS: Record = { + "fetch-one": "Fetch a single page from Notion by page ID", "fetch-ready": 'Fetch pages with status "Ready to publish" and transition to "Draft published"', "fetch-all": diff --git a/api-server/validation-schemas.test.ts b/api-server/validation-schemas.test.ts index 9a8111f3..6de59f80 100644 --- a/api-server/validation-schemas.test.ts +++ b/api-server/validation-schemas.test.ts @@ -100,7 +100,7 @@ describe("Validation Schemas - Job Type", () => { "invalid:type", "notion:invalid", "", - "notion:fetch-all-extra", + "fetch-all-extra", "NOTION:FETCH", // Case sensitive ]; @@ -115,14 +115,14 @@ describe("Validation Schemas - Job Type", () => { expect(result.success).toBe(false); if (!result.success && result.error) { expect(result.error.issues[0].message).toContain("Invalid option"); - expect(result.error.issues[0].message).toContain("notion:fetch"); + expect(result.error.issues[0].message).toContain("fetch-one"); } }); }); describe("validateJobType function", () => { it("should return validated job type for valid input", () => { - expect(validateJobType("notion:fetch")).toBe("notion:fetch"); + expect(validateJobType("fetch-one")).toBe("fetch-one"); }); it("should throw ZodError for invalid input", () => { @@ -175,12 +175,14 @@ describe("Validation Schemas - Job Options", () => { it("should accept valid options object", () => { const validOptions = [ { maxPages: 10 }, + { pageId: "page-123" }, { statusFilter: "In Progress" }, { force: true }, { dryRun: false }, { includeRemoved: true }, { maxPages: 10, + pageId: "page-123", statusFilter: "In Progress", force: true, dryRun: false, @@ -277,7 +279,7 @@ describe("Validation Schemas - Create Job Request", () => { it("should accept valid request with options", () => { const result = createJobRequestSchema.safeParse({ - type: "notion:fetch-all", + type: "fetch-all", options: { maxPages: 10, statusFilter: "In Progress", @@ -286,12 +288,26 @@ describe("Validation Schemas - Create Job Request", () => { }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.type).toBe("notion:fetch-all"); + expect(result.data.type).toBe("fetch-all"); expect(result.data.options).toBeDefined(); expect(result.data.options?.maxPages).toBe(10); } }); + it("should accept fetch-one requests with pageId", () => { + const result = createJobRequestSchema.safeParse({ + type: "fetch-one", + options: { + pageId: "page-123", + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("fetch-one"); + expect(result.data.options?.pageId).toBe("page-123"); + } + }); + it("should reject missing type field", () => { const result = createJobRequestSchema.safeParse({}); expect(result.success).toBe(false); @@ -309,11 +325,28 @@ describe("Validation Schemas - Create Job Request", () => { it("should reject invalid options", () => { const result = createJobRequestSchema.safeParse({ - type: "notion:fetch", + type: "fetch-one", options: { maxPages: "not a number" }, }); expect(result.success).toBe(false); }); + + it("should require pageId for fetch-one", () => { + const result = createJobRequestSchema.safeParse({ + type: "fetch-one", + }); + expect(result.success).toBe(false); + if (!result.success && result.error) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["options", "pageId"], + message: "pageId is required when type is fetch-one", + }), + ]) + ); + } + }); }); describe("validateCreateJobRequest function", () => { @@ -331,13 +364,13 @@ describe("Validation Schemas - Create Job Request", () => { describe("TypeScript type inference", () => { it("should correctly infer CreateJobRequest type", () => { const request: CreateJobRequest = { - type: "notion:fetch", + type: "fetch-one", options: { maxPages: 10, force: true, }, }; - expect(request.type).toBe("notion:fetch"); + expect(request.type).toBe("fetch-one"); }); }); }); @@ -372,7 +405,7 @@ describe("Validation Schemas - Jobs Query Parameters", () => { it("should accept both status and type filters", () => { const result = jobsQuerySchema.safeParse({ status: "completed", - type: "notion:fetch", + type: "fetch-one", }); expect(result.success).toBe(true); }); @@ -403,7 +436,7 @@ describe("Validation Schemas - Jobs Query Parameters", () => { it("should correctly infer JobsQuery type", () => { const query: JobsQuery = { status: "running", - type: "notion:fetch", + type: "fetch-one", }; expect(query.status).toBe("running"); }); @@ -412,10 +445,10 @@ describe("Validation Schemas - Jobs Query Parameters", () => { describe("Validation Helpers - safeValidate", () => { it("should return success with data for valid input", () => { - const result = safeValidate(jobTypeSchema, "notion:fetch"); + const result = safeValidate(jobTypeSchema, "fetch-one"); expect(result.success).toBe(true); if (result.success) { - expect(result.data).toBe("notion:fetch"); + expect(result.data).toBe("fetch-one"); } }); @@ -580,6 +613,14 @@ describe("Validation Schemas - Edge Cases", () => { } }); + it("should handle empty pageId", () => { + const result = jobOptionsSchema.safeParse({ pageId: "" }); + expect(result.success).toBe(false); + if (!result.success && result.error) { + expect(result.error.issues[0].message).toContain("cannot be empty"); + } + }); + it("should handle all boolean option variations", () => { const booleanOptions = ["force", "dryRun", "includeRemoved"] as const; @@ -607,7 +648,7 @@ describe("Validation Schemas - Edge Cases", () => { describe("Validation Schemas - Integration", () => { it("should validate complete create job request", () => { const request = { - type: "notion:fetch-all", + type: "fetch-all", options: { maxPages: 50, statusFilter: "In Progress", diff --git a/api-server/validation-schemas.ts b/api-server/validation-schemas.ts index 5fbfef24..97590298 100644 --- a/api-server/validation-schemas.ts +++ b/api-server/validation-schemas.ts @@ -115,7 +115,11 @@ export const jobIdSchema = z * - Derived from JOB_COMMANDS keys (single source of truth) */ export const jobTypeSchema = z.enum(VALID_JOB_TYPES as [string, ...string[]]); -export const createJobFetchTypeSchema = z.enum(["fetch-ready", "fetch-all"]); +export const createJobFetchTypeSchema = z.enum([ + "fetch-one", + "fetch-ready", + "fetch-all", +]); export const createJobTypeSchema = z.union([ jobTypeSchema, createJobFetchTypeSchema, @@ -145,6 +149,7 @@ export const jobOptionsSchema = z .int("maxPages must be an integer") .min(0, "maxPages must be greater than or equal to 0") .optional(), + pageId: z.string().min(1, "pageId cannot be empty").optional(), statusFilter: z.string().min(1, "statusFilter cannot be empty").optional(), force: z.boolean().optional(), dryRun: z.boolean().optional(), @@ -157,10 +162,23 @@ export const jobOptionsSchema = z * - type is required and must be a valid job type * - options is optional and must match jobOptionsSchema */ -export const createJobRequestSchema = z.object({ - type: createJobTypeSchema, - options: jobOptionsSchema.optional(), -}); +export const createJobRequestSchema = z + .object({ + type: createJobTypeSchema, + options: jobOptionsSchema.optional(), + }) + .superRefine((data, ctx) => { + if ( + data.type === "fetch-one" && + (!data.options?.pageId || data.options.pageId.length === 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "pageId is required when type is fetch-one", + path: ["options", "pageId"], + }); + } + }); // ============================================================================= // Query Parameter Schemas @@ -467,7 +485,7 @@ export function formatZodError( code = ErrorCode.INVALID_INPUT; const keys = (firstError as any).keys || []; const keyName = Array.isArray(keys) && keys.length > 0 ? keys[0] : field; - message = `Unknown option: '${keyName}'. Valid options are: maxPages, statusFilter, force, dryRun, includeRemoved`; + message = `Unknown option: '${keyName}'. Valid options are: maxPages, pageId, statusFilter, force, dryRun, includeRemoved`; details.field = keyName; } diff --git a/scripts/notion-fetch-all/__tests__/index.cli.test.ts b/scripts/notion-fetch-all/__tests__/index.cli.test.ts index 0bd213e1..3c3fce6c 100644 --- a/scripts/notion-fetch-all/__tests__/index.cli.test.ts +++ b/scripts/notion-fetch-all/__tests__/index.cli.test.ts @@ -93,16 +93,23 @@ vi.mock("ora", () => ({ })), })); -vi.mock("chalk", () => ({ - default: { - green: (text: string) => `[GREEN]${text}[/GREEN]`, - blue: (text: string) => `[BLUE]${text}[/BLUE]`, - yellow: (text: string) => `[YELLOW]${text}[/YELLOW]`, - red: (text: string) => `[RED]${text}[/RED]`, - cyan: (text: string) => `[CYAN]${text}[/CYAN]`, - gray: (text: string) => `[GRAY]${text}[/GRAY]`, - }, -})); +vi.mock("chalk", () => { + const bold = Object.assign((text: string) => `[BOLD]${text}[/BOLD]`, { + cyan: (text: string) => `[BOLD_CYAN]${text}[/BOLD_CYAN]`, + }); + + return { + default: { + green: (text: string) => `[GREEN]${text}[/GREEN]`, + blue: (text: string) => `[BLUE]${text}[/BLUE]`, + yellow: (text: string) => `[YELLOW]${text}[/YELLOW]`, + red: (text: string) => `[RED]${text}[/RED]`, + cyan: (text: string) => `[CYAN]${text}[/CYAN]`, + gray: (text: string) => `[GRAY]${text}[/GRAY]`, + bold, + }, + }; +}); describe("CLI index", () => { let restoreEnv: () => void; @@ -194,5 +201,56 @@ describe("CLI index", () => { // Runtime should be initialized expect(trackSpinner).toBeDefined(); }); + + it("should pass --page-id through to fetchAllNotionData", async () => { + const originalArgv = process.argv; + const rawPage = createMockNotionPage({ id: "page-123" }); + + process.argv = [ + originalArgv[0] ?? "bun", + originalArgv[1] ?? "scripts/notion-fetch-all/index.ts", + "--page-id", + "page-123", + "--preview-only", + "--no-analysis", + ]; + + (fetchAllNotionData as Mock).mockResolvedValue({ + pages: [ + { + id: "page-123", + title: "Single Page", + status: "Draft", + elementType: "Page", + order: 0, + subItems: [], + lastEdited: new Date(), + createdTime: new Date(), + properties: {}, + rawPage, + }, + ], + rawPages: [rawPage], + candidateIds: [], + fetchedCount: 1, + processedCount: 1, + }); + (PreviewGenerator.generatePreview as Mock).mockResolvedValue({ + sections: [], + }); + + try { + const { main } = await import("../index"); + await main(); + } finally { + process.argv = originalArgv; + } + + expect(fetchAllNotionData).toHaveBeenCalledWith( + expect.objectContaining({ + pageId: "page-123", + }) + ); + }); }); }); diff --git a/scripts/notion-fetch-all/fetchAll.test.ts b/scripts/notion-fetch-all/fetchAll.test.ts index a8a96d95..fa1f31bd 100644 --- a/scripts/notion-fetch-all/fetchAll.test.ts +++ b/scripts/notion-fetch-all/fetchAll.test.ts @@ -14,6 +14,10 @@ import { type PageWithStatus, } from "./fetchAll"; +const { mockNotionPageRetrieve } = vi.hoisted(() => ({ + mockNotionPageRetrieve: vi.fn(), +})); + // Mock sharp to avoid installation issues vi.mock("sharp", () => { const createPipeline = () => { @@ -39,6 +43,11 @@ vi.mock("sharp", () => { // Mock notionClient to avoid environment variable requirements vi.mock("../notionClient", () => ({ + notion: { + pages: { + retrieve: mockNotionPageRetrieve, + }, + }, enhancedNotion: { blocksChildrenList: vi.fn(), }, @@ -105,6 +114,36 @@ describe("fetchAll - Core Functions", () => { }); describe("fetchAllNotionData", () => { + it("should retrieve a single page when pageId is provided", async () => { + const { runFetchPipeline } = await import("../notion-fetch/runFetch"); + const mockPage = createMockNotionPage({ + id: "page-123", + title: "Single Page", + status: "Draft", + }); + + mockNotionPageRetrieve.mockResolvedValue(mockPage); + + const result = await fetchAllNotionData({ + pageId: "page-123", + statusFilter: "Ready to publish", + maxPages: 10, + }); + + expect(mockNotionPageRetrieve).toHaveBeenCalledWith({ + page_id: "page-123", + }); + expect(runFetchPipeline).not.toHaveBeenCalled(); + expect(result.pages).toHaveLength(1); + expect(result.pages[0].id).toBe("page-123"); + expect(result.pages[0].title).toBe("Single Page"); + expect(result.rawPages).toEqual([mockPage]); + expect(result.candidateIds).toEqual([]); + expect(result.fetchedCount).toBe(1); + expect(result.processedCount).toBe(1); + expect(result.metrics).toBeUndefined(); + }); + it("should fetch and transform pages successfully", async () => { const { runFetchPipeline } = await import("../notion-fetch/runFetch"); const mockPages = [ diff --git a/scripts/notion-fetch-all/fetchAll.ts b/scripts/notion-fetch-all/fetchAll.ts index 0d0ecdfb..45a9d1fd 100644 --- a/scripts/notion-fetch-all/fetchAll.ts +++ b/scripts/notion-fetch-all/fetchAll.ts @@ -1,6 +1,7 @@ import { NOTION_PROPERTIES } from "../constants"; import { runFetchPipeline } from "../notion-fetch/runFetch"; import { GenerateBlocksOptions } from "../notion-fetch/generateBlocks"; +import { notion } from "../notionClient"; import { getStatusFromRawPage, selectPagesWithPriority, @@ -24,6 +25,7 @@ export interface PageWithStatus { } export interface FetchAllOptions { + pageId?: string; includeRemoved?: boolean; sortBy?: "order" | "created" | "modified" | "title"; sortDirection?: "asc" | "desc"; @@ -58,6 +60,7 @@ export async function fetchAllNotionData( options: FetchAllOptions = {} ): Promise { const { + pageId, includeRemoved = false, sortBy = "order", sortDirection = "asc", @@ -70,6 +73,19 @@ export async function fetchAllNotionData( generateOptions = {}, } = options; + if (pageId) { + const rawPage = await notion.pages.retrieve({ page_id: pageId }); + const page = transformPage(rawPage as any); + + return { + pages: [page], + rawPages: [rawPage as Record], + candidateIds: [], + fetchedCount: 1, + processedCount: 1, + }; + } + const filter = buildStatusFilter(includeRemoved, statusFilter); let fetchedCount = 0; diff --git a/scripts/notion-fetch-all/index.ts b/scripts/notion-fetch-all/index.ts index bcf652df..fa3b0c93 100644 --- a/scripts/notion-fetch-all/index.ts +++ b/scripts/notion-fetch-all/index.ts @@ -31,6 +31,7 @@ interface CliOptions { verbose: boolean; outputFormat: "markdown" | "json" | "html"; outputFile?: string; + pageId?: string; includeRemoved: boolean; sortBy: "order" | "created" | "modified" | "title"; sortDirection: "asc" | "desc"; @@ -79,6 +80,9 @@ const parseArgs = (): CliOptions => { case "-o": options.outputFile = args[++i]; break; + case "--page-id": + options.pageId = args[++i]; + break; case "--include-removed": options.includeRemoved = true; break; @@ -153,6 +157,7 @@ const printHelp = () => { " --output-format, -f Output format: markdown, json, html (default: markdown)" ); console.log(" --output, -o Output file path"); + console.log(" --page-id Fetch a single Notion page by ID"); console.log( ' --include-removed Include pages with "Remove" status' ); @@ -233,6 +238,7 @@ async function main() { : undefined; const fetchOptions: FetchAllOptions = { + pageId: options.pageId, includeRemoved: options.includeRemoved, sortBy: options.sortBy, sortDirection: options.sortDirection, @@ -248,7 +254,8 @@ async function main() { force: options.force, dryRun: options.dryRun, // Only enable deletion when we have the full dataset (no filters/limits) - enableDeletion: !options.maxPages && !options.statusFilter, + enableDeletion: + !options.pageId && !options.maxPages && !options.statusFilter, }, };