393 lines
16 KiB
TypeScript
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");
|
|
});
|