Files
Jyotisha/frontend/tests/chat-session-write.test.ts
T

337 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
chatSessionMetadataPatchSchema,
chatSessionWriteSchema,
writeChatSession,
type ChatSessionWrite,
} from "../src/lib/chat-session-write-contract.ts";
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
import { homeSurface } from "./home-surface.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const values = {
title: "事业方向",
theme: "career",
model_id: "gpt-test",
messages: [{ role: "user", text: "你好" }],
session_type: "consultation",
rectification_case_id: null,
updated_at: "2026-07-22T00:00:00.000Z",
} satisfies ChatSessionWrite;
const createValues = {
title: values.title,
theme: values.theme,
model_id: values.model_id,
messages: [] as const,
session_type: values.session_type,
rectification_case_id: values.rectification_case_id,
};
const metadataPatch = {
title: values.title,
theme: values.theme,
model_id: values.model_id,
};
test("create schema keeps the client-generated session id after transcript limits", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
});
const id: string = parsed.id;
assert.equal(id, sessionId);
assert.equal(parsed.title, values.title);
assert.equal(parsed.messages.length, 0);
});
test("create schema accepts continued_from_session_id and rejects client-supplied context_summary", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
});
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in parsed, false);
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
const withSummary = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
context_summary: { version: 1, text: "injected" },
});
assert.equal(withSummary.success, false);
const badId = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
continued_from_session_id: "not-a-uuid",
});
assert.equal(badId.success, false);
});
test("create insert copies an owned summary and omits a missing one", () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: source,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.deepEqual(copied.context_summary, source);
assert.equal("continued_from_session_id" in copied, false);
const skipped = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: null,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.equal("context_summary" in skipped, false);
assert.equal(skipped.user_id, "owner");
});
test("create schema rejects a non-empty transcript", () => {
// Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a
// history-import path. Create is now only an empty session.
const parsed = chatSessionCreateSchema.safeParse({
id: sessionId,
...values,
});
assert.equal(parsed.success, false);
});
test("chat session schema accepts chart profile snapshots and keeps legacy writes valid", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
chart_profile_id: "other-profile-id",
chart_profile_name: "张三",
chart_profile_role: "other",
});
assert.equal(parsed.chart_profile_id, "other-profile-id");
assert.equal(parsed.chart_profile_name, "张三");
assert.equal(parsed.chart_profile_role, "other");
assert.equal(chatSessionWriteSchema.parse(values).chart_profile_id, undefined);
});
test("chat session schema preserves the safe agent execution receipt", () => {
const receipt = {
runId: "run-1",
runtime: "mastra-agentic" as const,
skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
techniqueTruth: "verified",
};
const workflowReceipt = {
route: "multi-domain",
status: "ready",
preciseTiming: "allowed",
missingLayers: [],
domains: ["general", "timing"] as const,
};
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }],
});
assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt);
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
});
test("chat session schema keeps sanitized thinking text on assistant messages", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", thinkingText: "先看今日节奏。" }],
});
assert.equal(parsed.messages[0]?.thinkingText, "先看今日节奏。");
});
test("chat session schema keeps structured thinking sections on assistant messages", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{
role: "assistant",
text: "回答",
thinkingSections: [{
id: "foundation",
title: "先整理本盘的统一参数",
heading: "统一参数与原始结构",
steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}],
}],
});
assert.equal(parsed.messages[0]?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
});
test("metadata patch accepts pin and archive fields without a transcript", () => {
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true });
// 原值:schema 接受 archived_at 并写入 patch
// 新值:schema 不含 archived_at;extract 读到后忽略
// 原因:BUG-991 归档下线,旧 bundle 仍可发送不得 400
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: "2026-09-01T00:00:00.000Z" }).success, false);
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: null }).success, false);
});
test("create write may carry continued_from_session_id and never context_summary", async () => {
const calls: Array<Record<string, unknown>> = [];
await writeChatSession(sessionId, {
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
}, "create", async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ ok: true }, { status: 201 });
});
assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in (calls[0] ?? {}), false);
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
calls.push({ url: String(url), init });
return Response.json({ ok: true });
});
assert.equal(calls.length, 1);
assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`);
assert.equal(calls[0]?.init?.method, "PATCH");
assert.equal(calls[0]?.init?.credentials, "same-origin");
assert.equal(JSON.parse(String(calls[0]?.init?.body)).messages, undefined);
});
test("transient Load failed is retried and never exposed as raw browser copy", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, metadataPatch, "update", async () => {
attempts += 1;
throw new TypeError("Load failed");
}),
(error: unknown) => error instanceof Error
&& error.message === "网络暂时不可用,云端记录尚未更新",
);
assert.equal(attempts, 2);
});
test("owner or validation failures are not retried", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, metadataPatch, "update", async () => {
attempts += 1;
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}),
/聊天记录不存在或已被删除/,
);
assert.equal(attempts, 1);
});
test("session writes reject oversized transcripts before they reach storage", () => {
const oversized = chatSessionWriteSchema.safeParse({
...values,
messages: [{ role: "user", text: "字".repeat(16_001) }],
});
const tooMany = chatSessionWriteSchema.safeParse({
...values,
messages: Array.from({ length: 201 }, () => ({ role: "user" as const, text: "你好" })),
});
const tooMuchText = chatSessionWriteSchema.safeParse({
...values,
messages: Array.from({ length: 20 }, () => ({ role: "user" as const, text: "字".repeat(12_000) })),
});
assert.equal(oversized.success, false);
assert.equal(tooMany.success, false);
assert.equal(tooMuchText.success, false);
});
test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => {
const page = homeSurface;
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8");
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
const migration = readFileSync(new URL("../supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql", import.meta.url), "utf8");
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
// Former value: persistSession mapped thinkingText/thinkingSections into a
// client transcript PATCH. Messages are now server-appended; this client
// write is metadata only.
assert.doesNotMatch(page, /thinkingText: message\.thinkingText/);
assert.doesNotMatch(page, /thinkingSections: message\.thinkingSections/);
assert.match(page, /messages: \[\] as const/);
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
assert.match(collectionRoute, /export async function POST/);
assert.match(itemRoute, /export async function GET/);
assert.match(itemRoute, /export async function PATCH/);
assert.match(itemRoute, /logIgnoredSessionMessages/);
assert.match(observability, /compat_messages_ignored/);
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /readChatSessionJson/);
assert.match(itemRoute, /readChatSessionJson/);
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
// Former value: `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data`
// New value: also peel `continued_from_session_id` so it is never inserted as a column.
assert.match(
collectionRoute,
/const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/,
);
assert.match(collectionRoute, /resolveInheritedContextSummary/);
assert.match(collectionRoute, /chatSessionCreateInsertRow/);
assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /status: 201/);
assert.doesNotMatch(collectionRoute, /context_summary: parsed/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
assert.match(page, /chartSnapshotForSession/);
assert.match(page, /chart_profile_name: session\.chartProfileName/);
assert.match(page, /emptySessionBindingWrite/);
const bind = page.slice(page.indexOf("async function bindEmptyChatSubject("), page.indexOf("async function openChatBoundToProfile("));
assert.doesNotMatch(bind, /chart_profile_name/);
// 原值 `分析对象:{...}` 副标题 / 新值 `.chat-header-chart` 静默 chip
// / 原因:顶栏从 68px 收到 46px 单行;「分析对象」是内部术语,标签去掉只留盘名。
// 会话与星盘资料的绑定关系本身没变,下面两条仍然锁住它。
assert.match(page, /className="chat-header-chart"/);
assert.match(page, /sessionChartLabel\(activeSession, chartLibrary\)/);
assert.match(migration, /add column if not exists chart_profile_id text/);
assert.match(migration, /grant insert \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
assert.match(migration, /grant update \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
});
test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => {
const page = homeSurface;
const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
assert.match(page, /fetch\("\/api\/account"/);
assert.match(page, /fetch\("\/api\/sessions"/);
// 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create")
// 新值:本地 createSession,第一问 send() 才 persistSession(..., "create")
// 原因:BUG-989 第一问之前不落库
assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
assert.match(page, /await persistSession\(currentSession, "create"\)/);
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
assert.match(accountRoute, /profile,/);
assert.doesNotMatch(accountRoute, /rectificationCase/);
});
test("metadata PATCH no longer writes updated_at", () => {
const values = metadataUpdateValues({ title: "半年内换工作时机" });
assert.ok(values);
assert.equal("updated_at" in values, false);
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(itemRoute, /updated_at: new Date\(\)\.toISOString\(\)/);
});