import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import test from "node:test"; import type { ReportDocumentV1 } from "../src/lib/personal-report-contract.ts"; import { createPersonalReportService, PersonalReportServiceError, PERSONAL_REPORT_FAILURE_CODES, type PersonalReportDataClient, type PersonalReportQueryBuilder, type PersonalReportQueryResult, } from "../src/lib/personal-report-service-core.ts"; const fixtureText = readFileSync( new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url), "utf8", ); const fixture: ReportDocumentV1 = JSON.parse(fixtureText); const clone = () => structuredClone(fixture) as ReportDocumentV1; const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); type MemoryRow = Record & { id: string; user_id: string; request_id: string; request_fingerprint: string; }; type LoggedOperation = Readonly<{ operation: "select" | "insert" | "update" | "delete"; columns: readonly string[]; values: Readonly>; }>; class MemoryPersonalReportClient implements PersonalReportDataClient { rows = new Map(); private nextId = 0; /** Recorded operations for owner-scoping privilege assertions. */ log: LoggedOperation[] = []; from(table: "personal_reports"): PersonalReportQueryBuilder { if (table !== "personal_reports") throw new Error("unexpected table"); return this.chain(this.newState()); } private newState() { return { operation: "select" as "select" | "insert" | "update" | "delete", values: {} as Record, filters: [] as { column: string; value: unknown }[], }; } private chain(state: ReturnType): PersonalReportQueryBuilder { // One shared state object per chain; every chained method mutates it and // returns a fresh view so .insert(...).select(...).single() works like the // real Supabase/local builders. const execute = (): PersonalReportQueryResult => { this.log.push({ operation: state.operation, columns: state.filters.map((f) => f.column), values: state.values }); const matches = [...this.rows.values()].filter((row) => state.filters.every(({ column, value }) => row[column] === value), ); if (state.operation === "select") { return { data: matches, error: null }; } if (state.operation === "insert") { const duplicate = [...this.rows.values()].some((row) => row.user_id === state.values.user_id && row.request_id === state.values.request_id); if (duplicate) { return { data: null, error: { code: "23505", message: "duplicate key (user_id, request_id)" } }; } const inflight = [...this.rows.values()].some((row) => row.user_id === state.values.user_id && row.status === "generating"); if (inflight) { return { data: null, error: { code: "23505", message: "one generating per user" } }; } const id = typeof state.values.id === "string" ? state.values.id : `00000000-0000-4000-8000-${String(this.nextId++).padStart(12, "0")}`; const row = { evidence_hash: null, calculation_hash: null, failure_code: null, completed_at: null, report_document: null, session_id: null, chart_profile_id: null, skill_source_commit: null, ...state.values, id, } as unknown as MemoryRow; this.rows.set(id, row); return { data: [row], error: null }; } if (state.operation === "update") { const updated: MemoryRow[] = []; for (const row of matches) { const next = { ...row, ...state.values } as MemoryRow; this.rows.set(row.id, next); updated.push(next); } return { data: updated, error: null }; } const deletedCount = matches.length; for (const row of matches) this.rows.delete(row.id); return { data: null, error: null, count: deletedCount }; }; const wrap = (operation: typeof state.operation, values: Record = {}) => { state.operation = operation; state.values = values; return this.chain(state); }; return { select: () => this.chain(state), insert: (row) => wrap("insert", row as Record), update: (values) => wrap("update", values as Record), delete: () => wrap("delete"), eq: (column, value) => { state.filters.push({ column, value }); return this.chain(state); }, order: () => this.chain(state), limit: () => this.chain(state), maybeSingle: () => { const result = execute(); const rows = Array.isArray(result.data) ? result.data : []; return Promise.resolve(rows.length > 1 ? { data: null, error: { code: "PGRST116", message: "unexpected row count" } } : { data: rows[0] ?? null, error: null }); }, single: () => { const result = execute(); const rows = Array.isArray(result.data) ? result.data : []; return Promise.resolve(rows.length === 1 ? { data: rows[0], error: null } : { data: null, error: { code: "PGRST116", message: "unexpected row count" } }); }, then: (onfulfilled, onrejected) => Promise.resolve(execute()).then(onfulfilled, onrejected), }; } } function setup(now = new Date("2026-08-06T08:00:00Z")) { const client = new MemoryPersonalReportClient(); const service = createPersonalReportService(client, { now: () => now }); return { client, service, now }; } const ownerId = "11111111-1111-4111-8111-111111111111"; const otherId = "22222222-2222-4222-8222-222222222222"; const requestId = "33333333-3333-4333-8333-333333333333"; const fingerprintA = sha256("request-intent-a"); const fingerprintB = sha256("request-intent-b"); function generatingInput(overrides: Record = {}) { return { userId: ownerId, requestId, requestFingerprint: fingerprintA, reportType: "personal_full" as const, presentationMode: "default" as const, requestedThemes: ["career", "marriage"], skillSnapshotSha256: "a".repeat(64), skillSourceCommit: "b".repeat(40), ...overrides, }; } test("createGenerating inserts a generating record with fingerprint", async () => { const { service } = setup(); const result = await service.createGenerating(generatingInput()); assert.equal(result.kind, "created"); if (result.kind !== "created") return; assert.equal(result.record.status, "generating"); assert.equal(result.record.requestFingerprint, fingerprintA); assert.equal(result.record.schemaVersion, "report_document.v1"); assert.deepEqual(result.record.requestedThemes, ["career", "marriage"]); assert.equal(result.record.reportDocument, null); assert.equal(result.record.failureCode, null); }); test("same requestId and fingerprint replays idempotently", async () => { const { service } = setup(); const first = await service.createGenerating(generatingInput()); assert.equal(first.kind, "created"); const replay = await service.createGenerating(generatingInput()); assert.equal(replay.kind, "replayed"); if (first.kind !== "created" || replay.kind !== "replayed") return; assert.equal(replay.record.id, first.record.id); const byRequest = await service.getByUserAndRequestId(ownerId, requestId); assert.equal(byRequest?.id, first.record.id); }); test("same requestId with a different fingerprint is request_conflict, never replay", async () => { const { service } = setup(); await service.createGenerating(generatingInput()); const conflict = await service.createGenerating(generatingInput({ requestFingerprint: fingerprintB })); assert.equal(conflict.kind, "request_conflict"); if (conflict.kind !== "request_conflict") return; assert.equal(conflict.record.requestFingerprint, fingerprintA); }); test("a second in-flight generation for the same user is generation_in_progress", async () => { const { service } = setup(); await service.createGenerating(generatingInput()); const second = await service.createGenerating(generatingInput({ requestId: "44444444-4444-4444-8444-444444444444", requestFingerprint: sha256("other-intent"), })); assert.equal(second.kind, "generation_in_progress"); }); test("createGenerating validates fingerprints, uuids and hashes", async () => { const { service } = setup(); const rejectsWithInvalidRequest = (input: Record) => assert.rejects( service.createGenerating(generatingInput(input)), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_request", ); await rejectsWithInvalidRequest({ requestFingerprint: "not-a-sha" }); await rejectsWithInvalidRequest({ requestId: "nope" }); await rejectsWithInvalidRequest({ skillSnapshotSha256: "short" }); await rejectsWithInvalidRequest({ reportType: "personal_unknown" }); await rejectsWithInvalidRequest({ userId: "not-a-uuid" }); }); test("reads are owner-scoped", async () => { const { service } = setup(); await service.createGenerating(generatingInput()); const own = await service.getByUserAndRequestId(ownerId, requestId); if (!own) throw new Error("missing own record"); assert.equal(await service.getByUserAndRequestId(otherId, requestId), null); const byId = await service.getOwnedById(ownerId, own.id); assert.equal(byId?.id, own.id); assert.equal(await service.getOwnedById(otherId, own.id), null); }); test("completeReady validates the contract and stores recomputed hashes", async () => { const { service, now } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const document = clone(); document.reportId = created.record.id; const ready = await service.completeReady(ownerId, created.record.id, document); assert.equal(ready.status, "ready"); assert.equal(ready.reportDocument?.schemaVersion, "report_document.v1"); assert.equal(ready.evidenceHash, fixture.provenance.evidenceHash); assert.equal(ready.calculationHash, document.provenance.calculationHash); assert.equal(ready.completedAt, now.toISOString()); }); test("completeReady rejects document reportId mismatch and tampered evidence", async () => { const { service } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const wrongId = clone(); wrongId.reportId = requestId; await assert.rejects( service.completeReady(ownerId, created.record.id, wrongId), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", ); const tampered = clone(); tampered.reportId = created.record.id; tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值"; await assert.rejects( service.completeReady(ownerId, created.record.id, tampered), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", ); }); test("completeReady rejects non-generating states and foreign owners", async () => { const { service } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const document = clone(); document.reportId = created.record.id; await service.markFailed(ownerId, created.record.id, "model_unavailable"); await assert.rejects( service.completeReady(ownerId, created.record.id, document), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", ); await assert.rejects( service.completeReady(otherId, created.record.id, document), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "not_found", ); }); test("markFailed uses only stable failure codes", async () => { const { service } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; await assert.rejects( service.markFailed(ownerId, created.record.id, "model said something bad"), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_failure_code", ); const failed = await service.markFailed(ownerId, created.record.id, "model_unavailable"); assert.equal(failed.status, "failed"); assert.equal(failed.failureCode, "model_unavailable"); assert.equal(failed.reportDocument, null); assert.equal(failed.completedAt, null); await assert.rejects( service.markFailed(ownerId, created.record.id, "report_rate_limited"), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", ); assert.deepEqual(PERSONAL_REPORT_FAILURE_CODES, [ "profile_incomplete", "birth_time_not_usable", "report_generation_in_progress", "report_rate_limited", "calculation_unavailable", "model_unavailable", "report_schema_invalid", "report_guard_rejected", "report_not_found", ]); }); test("deleteOwned removes only the owner's record", async () => { const { service, client } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; assert.equal(await service.deleteOwned(otherId, created.record.id), false); assert.ok(client.rows.has(created.record.id)); assert.equal(await service.deleteOwned(ownerId, created.record.id), true); assert.equal(client.rows.has(created.record.id), false); assert.equal(await service.deleteOwned(ownerId, created.record.id), false); }); test("every write and read operation is owner-scoped", async () => { const { service, client } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const document = clone(); document.reportId = created.record.id; await service.getOwnedById(ownerId, created.record.id); await service.getByUserAndRequestId(ownerId, requestId); await service.completeReady(ownerId, created.record.id, document); await service.deleteOwned(ownerId, created.record.id); for (const entry of client.log) { if (entry.operation === "insert") { assert.equal(entry.values.user_id, ownerId, "insert row must carry the caller user_id"); } else { assert.ok(entry.columns.includes("user_id"), `${entry.operation} missing user_id filter`); } } const statefulWrites = client.log.filter((entry) => entry.operation === "update" || entry.operation === "delete"); assert.ok(statefulWrites.length > 0); for (const entry of statefulWrites) { assert.ok(entry.columns.includes("id"), `${entry.operation} missing id filter`); } }); test("failed records are never implicitly resurrected by createGenerating", async () => { const { service } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; await service.markFailed(ownerId, created.record.id, "model_unavailable"); // Same requestId + same fingerprint after failure: replay reports the failed // record as-is; the service never flips it back to generating. const replay = await service.createGenerating(generatingInput()); assert.equal(replay.kind, "replayed"); if (replay.kind !== "replayed") return; assert.equal(replay.record.status, "failed"); assert.equal(replay.record.failureCode, "model_unavailable"); });