255 lines
12 KiB
TypeScript
255 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 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", "session-id-1");
|
|
assert.deepEqual(Object.keys(body).sort(), ["presentationMode", "reportType", "requestId", "sessionId", "themes"]);
|
|
assert.equal(body.requestId, "request-id-1");
|
|
assert.equal(body.sessionId, "session-id-1");
|
|
assert.equal(body.reportType, "personal_full");
|
|
assert.equal(body.presentationMode, "default");
|
|
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: sessionId is null when no real session exists", () => {
|
|
const body = buildPersonalReportCreateRequest("request-id-2", null);
|
|
assert.equal(body.sessionId, null);
|
|
});
|
|
|
|
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("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 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, sessionId\)/);
|
|
assert.match(componentSource, /body: JSON\.stringify\(body\)/);
|
|
assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/);
|
|
});
|
|
|
|
test("evidence honesty: ready vs unknown copy, never a fabricated boolean", () => {
|
|
assert.match(componentSource, /服务端将校验本次咨询是否存在可用证据/);
|
|
assert.match(componentSource, /最终以服务端校验为准/);
|
|
assert.match(pageSource, /workflowReceipt\s*\?\s*"ready"\s*:\s*"unknown"/);
|
|
assert.doesNotMatch(pageSource, /reportEvidenceState\s*=\s*true/);
|
|
});
|
|
|
|
test("entry is gated on authenticated chat surface with usable profile", () => {
|
|
assert.match(pageSource, /reportEntryVisible = !rectificationSurfaceOpen/);
|
|
assert.match(pageSource, /profileComplete/);
|
|
assert.match(pageSource, /sessionType === "consultation"/);
|
|
assert.match(pageSource, /GeneratePersonalReportButton/);
|
|
assert.match(pageSource, /sessionId=\{activeSession\.id\}/);
|
|
assert.match(pageSource, /evidenceState=\{reportEvidenceState\}/);
|
|
});
|
|
|
|
test("entry hides for reported/candidate birth time: client gate mirrors the API", () => {
|
|
// The API only accepts accepted/confirmed + usable active time (422 otherwise).
|
|
// The page must mirror that gate so reported/candidate users never see the CTA.
|
|
assert.match(pageSource, /isBirthTimeReadyForConsultation\(profile\)/);
|
|
assert.match(pageSource, /reportBirthTimeUsable/);
|
|
assert.match(pageSource, /&& reportBirthTimeUsable/);
|
|
assert.doesNotMatch(pageSource, /reportEntryVisible = [^;]*?birthTimeStatus === "reported"/);
|
|
assert.doesNotMatch(pageSource, /birthTimeStatus === "candidate"/);
|
|
|
|
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}生成个人报告/);
|
|
});
|