548 lines
15 KiB
TypeScript
548 lines
15 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("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();
|
|
});
|
|
});
|