import { describe, expect, it } from "vitest"; import { createServer } from "./server"; async function createTask(app: ReturnType, query: string) { const response = await app.inject({ method: "POST", url: "/api/tasks", payload: { query, perLinkLimit: 100, taskTotalLimit: 500 } }); return response.json().task; } async function preparePlatform(app: ReturnType, platform: "tmall" | "jd") { await app.inject({ method: "POST", url: `/api/platforms/${platform}/prepare` }); } describe("API server", () => { it("returns platform readiness with jd blocked by default", async () => { const app = createServer(); await app.ready(); const response = await app.inject({ method: "GET", url: "/api/platforms/readiness" }); expect(response.statusCode).toBe(200); const payload = response.json(); expect(payload.platforms).toHaveLength(2); expect( payload.platforms.find( (platform: { platform: string }) => platform.platform === "jd" ) ).toMatchObject({ platform: "jd", ready: false, searchRequirement: "required" }); await app.close(); }); it("exposes session state with a 24-hour expiry window after preparation", async () => { const app = createServer(); await app.ready(); const prepareResponse = await app.inject({ method: "POST", url: "/api/platforms/jd/prepare" }); expect(prepareResponse.statusCode).toBe(200); const preparedPayload = prepareResponse.json(); expect(preparedPayload).toMatchObject({ platform: "jd", session_ready: true, status: "ready", encrypted_snapshot_available: true }); expect( Date.parse(preparedPayload.expires_at) - Date.parse(preparedPayload.last_prepared_at) ).toBe(24 * 60 * 60 * 1000); const sessionResponse = await app.inject({ method: "GET", url: "/api/sessions/jd" }); expect(sessionResponse.statusCode).toBe(200); expect(sessionResponse.json().session).toMatchObject({ platform: "jd", ready: true, status: "ready", scope: "workspace", ttlHours: 24, encryptedSnapshotAvailable: true, cipherLabel: "mock-aes-gcm-v1" }); await app.close(); }); it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => { const app = createServer(); await app.ready(); const response = await app.inject({ method: "POST", url: "/api/tasks", payload: { query: "iPhone 15 Pro", perLinkLimit: 100, taskTotalLimit: 500 } }); expect(response.statusCode).toBe(201); const payload = response.json(); expect(payload.task.taskStatus).toBe("AwaitingConfirmation"); expect(payload.task.platformRuns).toEqual( expect.arrayContaining([ expect.objectContaining({ platform: "tmall", status: "AwaitingSelection" }), expect.objectContaining({ platform: "jd", status: "SearchBlocked" }) ]) ); await app.close(); }); it("records strategy attempts and observability metrics for platform search", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "iPhone 15 Pro"); const attemptsResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/strategy-attempts` }); expect(attemptsResponse.statusCode).toBe(200); expect(attemptsResponse.json().attempts).toEqual( expect.arrayContaining([ expect.objectContaining({ platform: "tmall", capability: "search", outcome: "succeeded", layer: "L1" }), expect.objectContaining({ platform: "jd", capability: "search", outcome: "blocked", layer: "L0", errorType: "session_required" }) ]) ); const overviewResponse = await app.inject({ method: "GET", url: "/api/observability/overview" }); expect(overviewResponse.statusCode).toBe(200); expect(overviewResponse.json().overview.strategyAttempts).toMatchObject({ total: 2, searchSuccessRate: 50 }); expect( overviewResponse .json() .overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd") ).toMatchObject({ platform: "jd", searchDurationMs: expect.any(Number) }); await app.close(); }); it("clears a prepared session and updates the readiness summary", async () => { const app = createServer(); await app.ready(); await preparePlatform(app, "jd"); const deleteResponse = await app.inject({ method: "DELETE", url: "/api/sessions/jd" }); expect(deleteResponse.statusCode).toBe(204); const sessionResponse = await app.inject({ method: "GET", url: "/api/sessions/jd" }); expect(sessionResponse.statusCode).toBe(200); expect(sessionResponse.json().session).toMatchObject({ platform: "jd", ready: false, status: "missing", encryptedSnapshotAvailable: false }); const readinessResponse = await app.inject({ method: "GET", url: "/api/platforms/readiness" }); expect( readinessResponse .json() .platforms.find((platform: { platform: string }) => platform.platform === "jd") ).toMatchObject({ platform: "jd", ready: false, status: "missing" }); await app.close(); }); it("supports NoSelection terminal state when user confirms nothing", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "DJI Pocket 3"); const confirmResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [] } }); expect(confirmResponse.statusCode).toBe(200); expect(confirmResponse.json().task.taskStatus).toBe("NoSelection"); const reportResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/report` }); expect(reportResponse.statusCode).toBe(404); await app.close(); }); it("generates a completed report after confirming at least one candidate", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "Nintendo Switch 2"); const candidatesResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/candidates` }); const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId; const confirmResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [ { platform: "tmall", candidateIds: [firstCandidateId] } ] } }); expect(confirmResponse.statusCode).toBe(200); expect(confirmResponse.json().task.taskStatus).toBe("Completed"); expect(confirmResponse.json().task.defaultReportVersion).toBe(1); const reportResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/report` }); expect(reportResponse.statusCode).toBe(200); expect(reportResponse.json().report.task_status).toBe("Completed"); expect(reportResponse.json().report.platform_insights).toHaveLength(2); await app.close(); }); it("retries a SearchBlocked platform after session preparation and restores candidates", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "iPhone 15 Pro"); await preparePlatform(app, "jd"); const retryResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` }); expect(retryResponse.statusCode).toBe(200); expect( retryResponse .json() .task.platformRuns.find((run: { platform: string }) => run.platform === "jd") ).toMatchObject({ platform: "jd", status: "AwaitingSelection" }); const candidatesResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/candidates` }); expect(candidatesResponse.json().candidates.jd).toHaveLength(3); await app.close(); }); it("records recovery audit entries and retry metrics for recovered platforms", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "iPhone 15 Pro"); await preparePlatform(app, "jd"); const retryResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` }); expect(retryResponse.statusCode).toBe(200); const auditResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/audit` }); expect(auditResponse.statusCode).toBe(200); expect(auditResponse.json().audit).toEqual( expect.arrayContaining([ expect.objectContaining({ action: "platform.retry_started", platform: "jd" }), expect.objectContaining({ action: "task.recovery_completed", platform: "jd" }), expect.objectContaining({ action: "platform.retry_completed", platform: "jd" }) ]) ); const overviewResponse = await app.inject({ method: "GET", url: "/api/observability/overview" }); expect( overviewResponse .json() .overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd") ).toMatchObject({ platform: "jd", retryCount: 1, recoveryCount: 1 }); expect(overviewResponse.json().overview.audits.recoveryActions).toBe(2); await app.close(); }); it("publishes a new report version when a blocked platform recovers successfully", async () => { const app = createServer(); await app.ready(); await preparePlatform(app, "jd"); const createdTask = await createTask(app, "Nintendo Switch 2 [jd:blocked-once]"); const candidatesResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/candidates` }); const candidates = candidatesResponse.json().candidates; const confirmResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [ { platform: "tmall", candidateIds: [candidates.tmall[0].candidateId] }, { platform: "jd", candidateIds: [candidates.jd[0].candidateId] } ] } }); expect(confirmResponse.statusCode).toBe(200); expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted"); expect(confirmResponse.json().task.reportVersions).toEqual([1]); const retryResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` }); expect(retryResponse.statusCode).toBe(200); expect(retryResponse.json().task.taskStatus).toBe("Completed"); expect(retryResponse.json().task.reportVersions).toEqual([1, 2]); expect(retryResponse.json().task.defaultReportVersion).toBe(2); const firstReport = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/report?version=1` }); const secondReport = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/report?version=2` }); expect(firstReport.json().report.task_status).toBe("PartialCompleted"); expect(secondReport.json().report.task_status).toBe("Completed"); await app.close(); }); it("keeps the current report version when retry does not change the result", async () => { const app = createServer(); await app.ready(); await preparePlatform(app, "jd"); const createdTask = await createTask(app, "Nintendo Switch 2 [jd:failed-always]"); const candidatesResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/candidates` }); const candidates = candidatesResponse.json().candidates; const confirmResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [ { platform: "tmall", candidateIds: [candidates.tmall[0].candidateId] }, { platform: "jd", candidateIds: [candidates.jd[0].candidateId] } ] } }); expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted"); expect(confirmResponse.json().task.reportVersions).toEqual([1]); const retryResponse = await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` }); expect(retryResponse.statusCode).toBe(200); expect(retryResponse.json().task.taskStatus).toBe("PartialCompleted"); expect(retryResponse.json().task.reportVersions).toEqual([1]); expect(retryResponse.json().task.defaultReportVersion).toBe(1); await app.close(); }); it("deletes a task together with its report snapshots", async () => { const app = createServer(); await app.ready(); const createdTask = await createTask(app, "Xbox Series X"); const candidatesResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/candidates` }); const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId; await app.inject({ method: "POST", url: `/api/tasks/${createdTask.taskId}/confirm`, payload: { selections: [ { platform: "tmall", candidateIds: [firstCandidateId] } ] } }); const deleteResponse = await app.inject({ method: "DELETE", url: `/api/tasks/${createdTask.taskId}` }); expect(deleteResponse.statusCode).toBe(204); const taskResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}` }); expect(taskResponse.statusCode).toBe(404); const reportResponse = await app.inject({ method: "GET", url: `/api/tasks/${createdTask.taskId}/report` }); expect(reportResponse.statusCode).toBe(404); const historyResponse = await app.inject({ method: "GET", url: "/api/history" }); expect( historyResponse .json() .tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId) ).toBe(false); const overviewResponse = await app.inject({ method: "GET", url: "/api/observability/overview" }); expect(overviewResponse.json().overview.retention).toMatchObject({ taskDeletes: 1, deletedReports: 1 }); expect(overviewResponse.json().overview.audits.deleteActions).toBe(1); await app.close(); }); });