639 lines
18 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createServer } from "./server";
async function createTask(app: ReturnType<typeof createServer>, 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<typeof createServer>, 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("does not rerun completed platforms when confirming a newly recovered platform", async () => {
const app = createServer();
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
const firstCandidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidates = firstCandidatesResponse.json().candidates;
const firstConfirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [firstCandidates.tmall[0].candidateId]
}
]
}
});
expect(firstConfirmResponse.statusCode).toBe(200);
expect(firstConfirmResponse.json().task.taskStatus).toBe("Completed");
expect(firstConfirmResponse.json().task.reportVersions).toEqual([1]);
const attemptsAfterFirstConfirm = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/strategy-attempts`
});
const firstTmallExecutionAttempts = attemptsAfterFirstConfirm
.json()
.attempts.filter(
(attempt: { platform: string; capability: string }) =>
attempt.platform === "tmall" &&
(attempt.capability === "detail" || attempt.capability === "reviews")
);
expect(firstTmallExecutionAttempts).toHaveLength(2);
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 recoveredCandidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const recoveredCandidates = recoveredCandidatesResponse.json().candidates;
const secondConfirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [recoveredCandidates.jd[0].candidateId]
}
]
}
});
expect(secondConfirmResponse.statusCode).toBe(200);
expect(secondConfirmResponse.json().task.taskStatus).toBe("Completed");
expect(secondConfirmResponse.json().task.reportVersions).toEqual([1, 2]);
const attemptsAfterSecondConfirm = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/strategy-attempts`
});
const executionAttempts = attemptsAfterSecondConfirm.json().attempts.filter(
(attempt: { platform: string; capability: string }) =>
attempt.capability === "detail" || attempt.capability === "reviews"
);
expect(
executionAttempts.filter((attempt: { platform: string }) => attempt.platform === "tmall")
).toHaveLength(2);
expect(
executionAttempts.filter((attempt: { platform: string }) => attempt.platform === "jd")
).toHaveLength(2);
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();
});
});