Files
Jyotisha/frontend/tests/chat-session-authority.test.ts
T
jesse-uxandGrok (agent loop) cd7e0a3493
Independent Staging Quality Gate / validate (push) Successful in 13m51s
Independent Staging Quality Gate / publish (push) Successful in 4m6s
把普通对话的预留、取消、追加、寒暄结算和用量计算收进结算工厂。
完成回答、直达校正、输出审核和开流前加载仍留在路由里,使 diff 停在 1500 行以内。

Co-Authored-By: Grok (agent loop)
2026-10-15 19:12:44 +08:00

168 lines
9.8 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 { homeSurface as page } from "./home-surface.ts";
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const consultSettlement = readFileSync(new URL("../src/lib/consult/settlement.ts", import.meta.url), "utf8");
const turnPrep = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
);
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
test("session list GET drops empty consultations without swallowing rectification rows", () => {
// 原值:.not("messages", "eq", []) 全表过滤,响应带 draft
// 新值:or(session_type.neq.consultation,messages.neq.[]),响应只有 sessions/nextCursor
// 原因:校正会话 messages 永远是 [](BUG-987);第一问前不落库后不再返回 draft(BUG-989)
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
assert.match(listRoute, /excludeEmptyConsultations/);
assert.match(listRoute, /session_type/);
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor \}\)/);
assert.doesNotMatch(listRoute, /draft/);
assert.doesNotMatch(itemRoute, /\.or\("session_type\.neq\.consultation,messages\.neq\.\[\]"\)/);
assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/);
});
test("session list GET omits messages while detail GET returns them", () => {
// Former list/detail column strings ended at updated_at; pinned and archived_at
// were added when those flags moved off localStorage.
// 原值:列表列含 created_at,供副标题用创建时间
// 新值:SESSION_LIST_COLUMNS 不含 created_at
// 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988)
assert.match(
listRoute,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
);
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/);
// 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
// 新值:WHERE 用 session_type 限定的 or(),SELECT 列仍不含 messages
// 原因:T1 空咨询过滤要看 messages,但不把正文带回列表
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
assert.doesNotMatch(
listRoute,
/SESSION_LIST_COLUMNS = "[^"]*context_summary/,
);
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
);
assert.match(page, /async function fetchSessionDetail\(/);
assert.match(page, /async function ensureSessionMessages\(/);
assert.match(page, /session-messages-loading/);
assert.match(page, /messagesHydrated: messagesPresent/);
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:consult route 里 /if (!chatSession) {[\s\S]*status: 404/,以及同文件的 session_not_consultation 与 409
// 新值:turn-prep.ts 里 sessionMissing() 为 404,notConsultation() 为 409 session_not_consultation,两处调用分开
// 原因:判定从 route.ts 挪进 turn-prep.ts;仍是查不到 404、类型不对 409,不得并成一个判断
assert.match(turnPrep, /function sessionMissing\(\)[\s\S]*status: 404/);
assert.match(turnPrep, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(turnPrep, /function notConsultation\(\)[\s\S]*code: "session_not_consultation"[\s\S]*status: 409/);
assert.match(
turnPrep,
/if \(chatSession\.session_type !== "consultation"\) return \{ ok: false, response: notConsultation\(\) \}/,
);
assert.doesNotMatch(
turnPrep,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
test("consult reads stored history and never applies the client history field", () => {
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
assert.match(consultRoute, /const storedHistory = historyWindow\.tail/);
assert.match(consultRoute, /const history = storedHistory/);
assert.doesNotMatch(consultRoute, /parsed\.data\.history/);
assert.match(sendSource, /history: currentSession\.messages\.slice\(-12\)/);
});
test("consult appends the user question after reserve and returns session_full", () => {
// 原值: route.ts 含 append_consultation_question、session_full 与写满文案
// 新值: 这些文本在 settlement.ts;route.ts 里 reserveCredit 早于 appendQuestion
// 原因: 追加问题搬进结算工厂,仍在预留成功之后
assert.match(consultSettlement, /accounting\.rpc\("append_consultation_question"/);
assert.match(consultSettlement, /error_code === "session_full"/);
assert.match(consultSettlement, /code: "session_full"/);
assert.match(consultSettlement, /这段对话已写满,开个新对话继续吧/);
assert.ok(consultRoute.indexOf("consultSettlement.reserveCredit") < consultRoute.indexOf("consultSettlement.appendQuestion"));
assert.match(sql, /create or replace function public\.append_consultation_question/);
assert.match(sql, /pg_advisory_xact_lock/);
assert.match(sql, /session_type = 'consultation'/);
assert.match(sql, /'session_full'::text/);
assert.match(sql, /grant execute on function public\.append_consultation_question/);
assert.match(sendSource, /caught\.code === "session_full"/);
assert.match(sendSource, /label: "开新对话"/);
assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/);
assert.match(page, /continuedFromSessionId: sourceSessionId/);
assert.doesNotMatch(page, /接着上次|继续上次聊|继承会话摘要|从上次对话继续/);
});
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
assert.match(itemRoute, /logIgnoredSessionMessages\(id, user\.id, ignoredMessageCount\(payload\)\)/);
assert.match(itemRoute, /payloadHasMessages\(payload\) \|\| payloadHasIgnoredArchive\(payload\)/);
assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/);
assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/);
});
test("PATCH compatibility accepts and ignores a legacy archived_at write", () => {
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
assert.match(itemRoute, /payloadHasIgnoredArchive/);
assert.match(itemRoute, /payloadHasMessages\(payload\) \|\| payloadHasIgnoredArchive\(payload\)/);
assert.match(contract, /Old bundles still send archived_at/);
assert.match(contract, /if \("archived_at" in record\) \{/);
assert.doesNotMatch(contract, /patch\.archived_at = record\.archived_at/);
assert.doesNotMatch(itemRoute, /\.update\([\s\S]*archived_at/);
});
test("pin and archive flags are session metadata, not localStorage", () => {
const pinSql = readFileSync(
new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url),
"utf8",
);
assert.match(pinSql, /add column if not exists pinned boolean not null default false/);
assert.match(pinSql, /add column if not exists archived_at timestamptz null/);
assert.match(pinSql, /grant update \(pinned, archived_at\)/);
assert.match(page, /function applyLegacySessionControls\(/);
assert.match(page, /writeChatSession\(sessionId, \{ pinned: nextPinned \}, "update"\)/);
// 原值:writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update")
// 新值:不再写 archived_at
// 原因:BUG-991 归档能力整体下线,删除会话路径不动
assert.doesNotMatch(page, /archived_at: nextArchivedAt/);
assert.match(page, /discardLegacyCloudMirrorKeys\(/);
assert.doesNotMatch(page, /setPinnedSessionIds/);
assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/);
assert.doesNotMatch(page, /writeSynastryHistory\(/);
});
test("an in-progress chat is not rebound when another person is chosen", () => {
const sessions = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
const open = sessions.slice(
sessions.indexOf("async function openChatBoundToProfile("),
sessions.indexOf("\n return {", sessions.indexOf("async function openChatBoundToProfile(")),
);
assert.doesNotMatch(open, /writeChatSession\(/);
assert.match(open, /pendingSessionId \|\| cancellationPending/);
assert.match(itemRoute, /authoritativeSessionBinding/);
assert.match(listRoute, /authoritativeSessionBinding/);
});
test("session list GET pages by cursor and returns pinned on the first page", () => {
assert.match(listRoute, /clampSessionLimit/);
assert.match(listRoute, /parseSessionCursor/);
assert.match(listRoute, /eq\("pinned", false\)/);
assert.match(listRoute, /eq\("pinned", true\)/);
assert.match(listRoute, /\.is\("archived_at", null\)/);
assert.doesNotMatch(listRoute, /isArchivedSessionQuery/);
assert.match(listRoute, /nextCursor/);
assert.match(listRoute, /limit \+ 1/);
});