import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import test from "node:test"; import { LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION, REPORT_DOCUMENT_SCHEMA_VERSION, type ReportDocumentV1, type ReportDocumentV2, } 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 fixtureV2Text = readFileSync( new URL("../../tests/fixtures/personal_report_document.v2.json", import.meta.url), "utf8", ); const fixtureV2: ReportDocumentV2 = JSON.parse(fixtureV2Text); const cloneV2 = () => structuredClone(fixtureV2) as ReportDocumentV2; 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_name: null, skill_version: 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"); const skillName = "jyotish-vedic-astrology"; const skillVersion = "6.9.14"; const skillSnapshotSha256 = "a".repeat(64); const skillSourceCommit = "b".repeat(40); function generatingInput(overrides: Record = {}) { return { userId: ownerId, requestId, requestFingerprint: fingerprintA, reportType: "personal_full" as const, presentationMode: "default" as const, depth: "standard" as const, requestedThemes: ["career", "marriage"], skillName, skillVersion, skillSnapshotSha256, skillSourceCommit, ...overrides, }; } function matchingDocument(reportId: string): ReportDocumentV1 { const document = clone(); document.reportId = reportId; document.provenance.skillName = skillName; document.provenance.skillVersion = skillVersion; document.provenance.skillSnapshotSha256 = skillSnapshotSha256; document.provenance.skillSourceCommit = skillSourceCommit; return document; } function matchingDocumentV2(reportId: string): ReportDocumentV2 { const document = cloneV2(); document.reportId = reportId; document.provenance.skillName = skillName; document.provenance.skillVersion = skillVersion; document.provenance.skillSnapshotSha256 = skillSnapshotSha256; document.provenance.skillSourceCommit = skillSourceCommit; return document; } 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_SCHEMA_VERSION); assert.equal(result.record.depth, "standard"); assert.deepEqual(result.record.requestedThemes, ["career", "marriage"]); assert.equal(result.record.reportDocument, null); assert.equal(result.record.failureCode, null); assert.equal(result.record.skillName, skillName); assert.equal(result.record.skillVersion, skillVersion); assert.equal(result.record.skillSnapshotSha256, skillSnapshotSha256); assert.equal(result.record.skillSourceCommit, skillSourceCommit); }); 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 request and full Skill identity", 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({ skillName: "Bad Package" }); await rejectsWithInvalidRequest({ skillVersion: "v6" }); await rejectsWithInvalidRequest({ skillSnapshotSha256: "short" }); await rejectsWithInvalidRequest({ skillSourceCommit: "short" }); await rejectsWithInvalidRequest({ reportType: "personal_unknown" }); await rejectsWithInvalidRequest({ userId: "not-a-uuid" }); }); test("migration adds nullable Personal Report Skill name and version columns", () => { const migration = readFileSync( new URL("../supabase/migrations/20260814010000_immutable_skill_registry.sql", import.meta.url), "utf8", ); assert.match(migration, /alter table public\.personal_reports/); assert.match(migration, /add column if not exists skill_name text/); assert.match(migration, /skill_name is null/); assert.match(migration, /add column if not exists skill_version text/); assert.match(migration, /skill_version is null/); }); 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 preserves legacy v1 documents for dual-read compatibility", async () => { const { service, now } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const document = matchingDocument(created.record.id); const ready = await service.completeReady(ownerId, created.record.id, document); assert.equal(ready.status, "ready"); assert.equal(ready.schemaVersion, LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION); assert.equal(ready.reportDocument?.schemaVersion, LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION); assert.equal(ready.evidenceHash, fixture.provenance.evidenceHash); assert.equal(ready.calculationHash, document.provenance.calculationHash); assert.equal(ready.completedAt, now.toISOString()); assert.equal(ready.reportDocument?.provenance.skillName, skillName); assert.equal(ready.reportDocument?.provenance.skillVersion, skillVersion); }); test("completeReady persists current v2 schema_version with the v2 document", async () => { const { client, service, now } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const document = matchingDocumentV2(created.record.id); const ready = await service.completeReady(ownerId, created.record.id, document); assert.equal(ready.status, "ready"); assert.equal(ready.schemaVersion, REPORT_DOCUMENT_SCHEMA_VERSION); assert.equal(ready.reportDocument?.schemaVersion, REPORT_DOCUMENT_SCHEMA_VERSION); assert.equal(client.rows.get(created.record.id)?.schema_version, REPORT_DOCUMENT_SCHEMA_VERSION); assert.equal(ready.evidenceHash, document.provenance.evidenceHash); assert.equal(ready.calculationHash, document.provenance.calculationHash); assert.equal(ready.completedAt, now.toISOString()); }); test("completeReady rejects every Skill identity mismatch", async () => { const { service } = setup(); const created = await service.createGenerating(generatingInput()); assert.equal(created.kind, "created"); if (created.kind !== "created") return; const mismatches: Array<[keyof ReportDocumentV1["provenance"], string | null | undefined]> = [ ["skillName", "other-skill"], ["skillVersion", "6.9.15"], ["skillSnapshotSha256", "c".repeat(64)], ["skillSourceCommit", null], ]; for (const [field, value] of mismatches) { const document = matchingDocument(created.record.id); Object.assign(document.provenance, { [field]: value }); await assert.rejects( service.completeReady(ownerId, created.record.id, document), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", `expected ${field} mismatch to be rejected`, ); } const missingName = matchingDocument(created.record.id); delete missingName.provenance.skillName; await assert.rejects( service.completeReady(ownerId, created.record.id, missingName), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", ); }); 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 = matchingDocument(requestId); await assert.rejects( service.completeReady(ownerId, created.record.id, wrongId), (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", ); const tampered = matchingDocument(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 = matchingDocument(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 = matchingDocument(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"); });