import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { archiveOrderBatch, parseOrderBatch, postOrderBatch, sidebarOrderBatch, } from "../src/lib/order-batch.ts"; test("dragging 60 chats sends one order request for the whole group", async () => { const sessions = Array.from({ length: 60 }, () => ({ id: randomUUID(), sidebarOrder: null as number | null, })); const ordered = sessions.map((session) => session.id).reverse(); const batch = sidebarOrderBatch(ordered, sessions, () => true); assert.ok(batch); assert.equal(batch.ids.length, 60); assert.deepEqual(batch.orders, ordered.map((_, index) => (index + 1) * 10)); const calls: Array<{ url: string; method: string; body: string }> = []; await postOrderBatch("/api/sessions/order", batch, async (url, init) => { calls.push({ url: String(url), method: String(init?.method), body: String(init?.body), }); return new Response(JSON.stringify({ ok: true }), { status: 200 }); }); assert.equal(calls.length, 1); assert.equal(calls[0]?.url, "/api/sessions/order"); assert.equal(calls[0]?.method, "POST"); const body = JSON.parse(calls[0]?.body ?? "{}") as { ids: string[] }; assert.equal(body.ids.length, 60); }); test("an unsaved chat stays out of the order request, and a failed request throws", async () => { const local = { id: randomUUID(), sidebarOrder: null as number | null }; const cloud = { id: randomUUID(), sidebarOrder: null as number | null }; const batch = sidebarOrderBatch( [local.id, cloud.id], [local, cloud], (session) => session.id === cloud.id, ); assert.deepEqual(batch, { ids: [cloud.id], orders: [20] }); await assert.rejects( () => postOrderBatch("/api/sessions/order", batch!, async () => new Response("{}", { status: 500 })), /排序没有保存/, ); }); test("archive order is one batch and never includes self", () => { const first = randomUUID(); const second = randomUUID(); assert.equal(archiveOrderBatch(["self", first], [{ id: first, archiveOrder: 10 }]), null); assert.deepEqual( archiveOrderBatch(["self", second, first], [ { id: first, archiveOrder: 10 }, { id: second, archiveOrder: 20 }, ]), { ids: [second, first], orders: [10, 20] }, ); }); test("order batch rejects a short, duplicate, or non-integer body", () => { const id = "11111111-1111-4111-8111-111111111449"; assert.equal(parseOrderBatch(null), null); assert.equal(parseOrderBatch({ ids: [], orders: [] }), null); assert.equal(parseOrderBatch({ ids: [id], orders: [1, 2] }), null); assert.equal(parseOrderBatch({ ids: [id, id], orders: [10, 20] }), null); assert.equal(parseOrderBatch({ ids: [id], orders: [1.5] }), null); assert.equal(parseOrderBatch({ ids: ["self"], orders: [10] }), null); assert.deepEqual(parseOrderBatch({ ids: [id], orders: [10] }), { ids: [id], orders: [10] }); }); test("reorder and archive each post one batch, and the migration stays out of db/migrations", () => { // 原值:读 src/hooks/use-session-management.ts // 新值:读 src/hooks/use-shell-session-actions.ts // 原因:issue #116 排序搬进共用外壳,每个页面都能拖;断言内容不变 const hook = readFileSync(new URL("../src/hooks/use-shell-session-actions.ts", import.meta.url), "utf8"); const reorder = hook.slice(hook.indexOf("function reorderSessions"), hook.indexOf("function togglePinnedSession")); assert.match(reorder, /postOrderBatch\(\s*"\/api\/sessions\/order"/); assert.doesNotMatch(reorder, /Promise\.allSettled|writeChatSession\(/); assert.match(reorder, /排序没有保存/); const page = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); const archive = page.slice(page.indexOf("async function persistArchiveOrder"), page.indexOf("function openPerson")); assert.match(archive, /postOrderBatch\(\s*"\/api\/chart-profiles\/order"/); assert.doesNotMatch(archive, /\/api\/chart-profiles\/\$\{/); const sessionRoute = readFileSync(new URL("../src/app/api/sessions/order/route.ts", import.meta.url), "utf8"); assert.equal(sessionRoute.match(/consumeUserRequestRateLimit\(/g)?.length, 1); assert.match(sessionRoute, /set_chat_session_sidebar_order/); const archiveRoute = readFileSync(new URL("../src/app/api/chart-profiles/order/route.ts", import.meta.url), "utf8"); assert.match(archiveRoute, /set_chart_profile_archive_order/); assert.doesNotMatch(archiveRoute, /consumeUserRequestRateLimit/); const sql = readFileSync( new URL("../supabase/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url), "utf8", ); assert.match(sql, /security invoker/); assert.doesNotMatch(sql, /security definer/i); assert.match(sql, /profile\.role = 'other'/); assert.match(sql, /raise exception 'order_row_missing'/); assert.equal( existsSync(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url)), false, ); });