Files
Jyotisha/frontend/tests/chat-session-authority.test.ts
T
jesse-uxandGrok (agent loop) 352d45b2ef
Independent Staging Quality Gate / validate (push) Failing after 9m12s
Independent Staging Quality Gate / publish (push) Skipped
外壳换成组件库:侧栏可拖宽,会话可排序(#105)。
桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。

自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。

Co-Authored-By: Grok (agent loop)
2026-10-25 01:41:19 +08:00

174 lines
10 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)
// 原值:列表列到 chart_profile_role 后直接是 updated_at
// 新值:中间增加 synastry_report_id,synastry_section,synastry_partner_subject_id
// 原因:合盘追问要在侧栏显示任一方,并在报告删除后仍能读到节与对方
// 原值:列表列到 pinned,archived_at
// 新值:末尾加 sidebar_order,仍不含 created_at 与 messages
// 原因:K1 只加可空排序列,列表排序查询仍按 updated_at
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,synastry_report_id,synastry_section,synastry_partner_subject_id,updated_at,pinned,archived_at,sidebar_order"/,
);
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,synastry_report_id,synastry_section,synastry_partner_subject_id,created_at,updated_at,pinned,archived_at,sidebar_order"/,
);
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/);
});