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 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", () => { // 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」 // 新值:查不到 404;类型不对 409 session_not_consultation // 原因:BUG-925 前端无法区分不存在与类型不对 assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/); assert.match(consultRoute, /code: "session_not_consultation"/); assert.match(consultRoute, /status: 409/); assert.doesNotMatch( consultRoute, /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", () => { assert.match(consultRoute, /accounting\.rpc\("append_consultation_question"/); assert.match(consultRoute, /error_code === "session_full"/); assert.match(consultRoute, /code: "session_full"/); assert.match(consultRoute, /这段对话已写满,开个新对话继续吧/); 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/); });