Files
Jyotisha/frontend/tests/personal-report-entry.test.ts

260 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildPersonalReportCreateRequest,
classifyCreateResponse,
createReportRequestId,
DEFAULT_REPORT_THEMES,
} from "../src/components/personal-report/generate-personal-report-button.tsx";
import { isBirthTimeReadyForConsultation } from "../src/lib/birth-time-intake-model.ts";
import {
consultationReportMarkdown,
downloadMarkdownReport,
} from "../src/lib/consultation-report-export.ts";
const componentSource = readFileSync(
new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const reportCenterSource = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
"utf8",
);
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("request shape: POST /api/reports body carries only report identity fields", () => {
const body = buildPersonalReportCreateRequest("request-id-1");
assert.deepEqual(Object.keys(body).sort(), ["depth", "presentationMode", "reportType", "requestId", "themes"]);
assert.equal(body.requestId, "request-id-1");
assert.equal(body.reportType, "personal_full");
assert.equal(body.presentationMode, "default");
assert.equal(body.depth, "standard");
assert.deepEqual(body.themes, ["career", "marriage", "wealth", "timing"]);
assert.deepEqual(DEFAULT_REPORT_THEMES, ["career", "marriage", "wealth", "timing"]);
// No birth data, no chat text, no profile payload.
assert.doesNotMatch(JSON.stringify(body), /birth|birthDate|latitude|longitude|timezone|profile|message|text/i);
});
test("request shape: global reports never carry a session identifier", () => {
const body = buildPersonalReportCreateRequest("request-id-2");
assert.equal("sessionId" in body, false);
});
test("requestId is a uuid v4", () => {
assert.match(createReportRequestId() ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
const cryptoAny = globalThis.crypto as unknown as { randomUUID: () => string };
const original = cryptoAny.randomUUID;
cryptoAny.randomUUID = () => "fixed-uuid-value";
assert.equal(createReportRequestId(), "fixed-uuid-value");
cryptoAny.randomUUID = original;
});
test("without crypto.randomUUID the request id is null and no request is sent", () => {
// Simulate an environment without a secure request identifier.
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true });
try {
assert.equal(createReportRequestId(), null);
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "crypto", descriptor);
}
}
// The handler must abort before fetch and show a browser hint instead.
assert.match(componentSource, /if \(requestId === null\)/);
assert.match(componentSource, /请使用支持安全请求标识的现代浏览器/);
const abortCheck = componentSource.indexOf("if (requestId === null)");
const fetchCall = componentSource.indexOf('fetch("/api/reports"');
assert.ok(abortCheck >= 0 && fetchCall > abortCheck, "null requestId must abort before fetch");
// No Math.random fallback may ever mint a weak request id.
assert.doesNotMatch(componentSource, /Math\.random/);
assert.doesNotMatch(componentSource, /xxxxxxxx-xxxx-4xxx/);
});
test("202 accepted, 201 created and 200 ready replay navigate to the report reader", () => {
const report = {
id: "report-id-1",
requestId: "req",
reportType: "personal_full",
presentationMode: "default",
status: "ready",
failureCode: null,
createdAt: "2026-08-06T00:00:00Z",
completedAt: null,
};
const created = classifyCreateResponse(201, { report });
assert.deepEqual(created, { kind: "navigate", reportId: "report-id-1" });
const accepted = classifyCreateResponse(202, { report: { ...report, status: "generating" } });
assert.deepEqual(accepted, { kind: "navigate", reportId: "report-id-1" });
const replayed = classifyCreateResponse(200, { report, reportDocument: { schemaVersion: "report_document.v1" } });
assert.deepEqual(replayed, { kind: "navigate", reportId: "report-id-1" });
});
test("failed replay never navigates", () => {
const outcome = classifyCreateResponse(200, {
report: {
id: "report-id-2",
status: "failed",
failureCode: "model_unavailable",
},
});
assert.equal(outcome.kind, "hint");
assert.ok(outcome.kind === "hint" && /未成功|重试/.test(outcome.message));
});
test("409 generating/conflict, 422 profile, 403, 429, 400, 5xx all become friendly hints", () => {
const cases: Array<[number, unknown, RegExp]> = [
[409, { error: "已有报告正在生成中", code: "report_generation_in_progress" }, /生成中/],
[409, { error: "请求内容与已有记录不一致", code: "report_request_conflict" }, /不一致/],
[409, {}, /生成中|不一致/],
[422, { error: "出生时间尚未达到可用状态", code: "birth_time_not_usable" }, /出生|资料/],
[422, {}, /出生资料/],
[403, { error: "个人报告功能暂未开放", code: "report_export_disabled" }, /暂未开放/],
[403, {}, /会话校验|无法生成/],
[429, { error: "今日报告生成次数已达上限", code: "report_rate_limited" }, /上限/],
[400, {}, /格式/],
[502, { error: "报告模型暂不可用", code: "model_unavailable" }, /不可用/],
[503, {}, /稍后重试/],
[401, {}, /登录/],
];
for (const [status, json, pattern] of cases) {
const outcome = classifyCreateResponse(status, json);
assert.equal(outcome.kind, "hint", `status ${status}`);
assert.ok(outcome.kind === "hint" && pattern.test(outcome.message), `status ${status}: ${outcome.message}`);
}
});
test("409/403 branches key on the server's real stable codes", () => {
// Code-only bodies (no server error text) prove the branch is code-driven.
const conflict = classifyCreateResponse(409, { code: "report_request_conflict" });
assert.ok(conflict.kind === "hint" && /不一致/.test(conflict.message));
const inProgress = classifyCreateResponse(409, { code: "report_generation_in_progress" });
assert.ok(inProgress.kind === "hint" && /生成中/.test(inProgress.message));
const disabled = classifyCreateResponse(403, { code: "report_export_disabled" });
assert.ok(disabled.kind === "hint" && /暂未开放/.test(disabled.message));
const notOwned = classifyCreateResponse(403, { code: "report_resource_forbidden" });
assert.ok(notOwned.kind === "hint" && /会话校验|无法生成/.test(notOwned.message));
// The comparisons use the stable codes from REPORT_STABLE_CODES, not short aliases.
assert.match(componentSource, /code === "report_request_conflict"/);
assert.match(componentSource, /code === "report_export_disabled"/);
assert.doesNotMatch(componentSource, /code === "request_conflict"/);
assert.doesNotMatch(componentSource, /code === "export_disabled"/);
});
test("repeat clicks are guarded while a request is in flight", () => {
assert.match(componentSource, /if \(inFlight\.current \|\| submitting\)/);
assert.match(componentSource, /inFlight\.current = true/);
assert.match(componentSource, /disabled=\{submitting\}/);
assert.match(componentSource, /正在生成/);
});
test("entry stays on one line and sends failures to the existing toast", () => {
assert.match(componentSource, /shrink-0/);
assert.match(componentSource, /whitespace-nowrap/);
assert.match(componentSource, /toast\.error\(outcome\.message\)/);
assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/);
assert.doesNotMatch(componentSource, /personal-report-entry-note/);
assert.doesNotMatch(componentSource, /flex-wrap/);
assert.match(globalStyles, /\.chat-header-actions \{[^}]*min-width: max-content;[^}]*display: flex;[^}]*white-space: nowrap;/);
});
test("entry never requests a server PDF and never prints directly", () => {
assert.doesNotMatch(componentSource, /api\/report_artifact/);
assert.doesNotMatch(componentSource, /window\.print/);
assert.doesNotMatch(componentSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
assert.doesNotMatch(componentSource, /getContext\s*\(|toDataURL|base64/i);
});
test("no birth data or chat text is sent from the entry component", () => {
// The request body is built only by buildPersonalReportCreateRequest, whose
// keys are exactly the report identity fields (covered above). The component
// posts that body as-is.
assert.match(componentSource, /const body = buildPersonalReportCreateRequest\(requestId\)/);
assert.match(componentSource, /body: JSON\.stringify\(body\)/);
assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/);
});
test("global report copy uses the canonical profile, preserves accepted/confirmed truth, and makes background work explicit", () => {
assert.match(componentSource, /authenticated user's canonical profile/);
assert.match(reportCenterSource, /已采用或已确认的出生资料/);
assert.match(reportCenterSource, /与某一次对话无关/);
assert.match(reportCenterSource, /后台继续处理/);
assert.match(componentSource, /根据已采用或已确认的个人出生资料生成完整报告/);
assert.doesNotMatch(reportCenterSource, /基于你已确认的出生资料生成/);
assert.doesNotMatch(componentSource, /根据已确认的个人出生资料生成完整报告/);
assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/);
});
test("entry is global in the sidebar and absent from the active session header", () => {
assert.match(sidebarSource, /我的报告/);
assert.match(sidebarSource, /onOpenReports/);
assert.match(pageSource, /onOpenReports=\{\(\) => window\.location\.assign\("\/reports"\)\}/);
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
assert.match(
globalStyles,
/\.report-nav-button \{[^}]*display: flex;[^}]*align-items: center;[^}]*justify-content: center;/,
);
});
test("reported/candidate birth times remain unusable for server-side report creation", () => {
const draftBase = {
date: "1990-01-01",
time: "12:00",
reportedTime: "12:00",
birthTimeSource: "family_exact" as const,
birthTimePeriod: "morning" as const,
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
};
// reported/candidate must never be treated as usable (no fake candidate boundary).
assert.equal(
isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "reported" }),
false,
"reported time must not show the entry",
);
assert.equal(
isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "candidate" }),
false,
"candidate time must not show the entry",
);
assert.equal(
isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "accepted" }),
true,
);
assert.equal(
isBirthTimeReadyForConsultation({ ...draftBase, birthTimeStatus: "confirmed" }),
true,
);
// A missing active time is not usable even when the status is accepted.
assert.equal(
isBirthTimeReadyForConsultation({ ...draftBase, time: "", birthTimeStatus: "accepted" }),
false,
);
});
test("legacy consultation Markdown export is untouched and still works", () => {
const markdown = consultationReportMarkdown({
title: "事业咨询",
messages: [
{ role: "user", text: "未来一年事业如何?" },
{
role: "assistant",
text: "先看阶段,不承诺具体日期。",
techniqueTruth: "partial",
workflowReceipt: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: ["MEVG"] },
},
],
});
assert.match(markdown, /# 事业咨询/);
assert.match(markdown, /workflow_route: career/);
assert.match(markdown, /precise_timing: blocked/);
assert.equal(typeof downloadMarkdownReport, "function");
assert.match(pageSource, /consultation-report-export/);
assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
});