Files
Jyotisha/frontend/tests/personal-report-service.test.ts
T

393 lines
16 KiB
TypeScript

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<string, unknown> & {
id: string;
user_id: string;
request_id: string;
request_fingerprint: string;
};
type LoggedOperation = Readonly<{
operation: "select" | "insert" | "update" | "delete";
columns: readonly string[];
values: Readonly<Record<string, unknown>>;
}>;
class MemoryPersonalReportClient implements PersonalReportDataClient {
rows = new Map<string, MemoryRow>();
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<string, unknown>,
filters: [] as { column: string; value: unknown }[],
};
}
private chain(state: ReturnType<MemoryPersonalReportClient["newState"]>): 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<string, unknown> = {}) => {
state.operation = operation;
state.values = values;
return this.chain(state);
};
return {
select: () => this.chain(state),
insert: (row) => wrap("insert", row as Record<string, unknown>),
update: (values) => wrap("update", values as Record<string, unknown>),
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<string, unknown> = {}) {
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<string, unknown>) =>
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");
});