Files
Jyotisha/frontend/tests/order-batch.test.ts
T
Jesse_ChenandClaude Opus 5.5 0f0fea325e
Independent Staging Quality Gate / validate (push) Failing after 15m9s
Independent Staging Quality Gate / publish (push) Skipped
Sidebar session menu and drag on every page (issue #116)
Rename, delete with confirmation, pin, share and reorder moved from Home's
session hook into the shared (app) shell, so /chart, /ephemeris, /reports
and /people rows get the same menu and drag as /. Home only adds its paging
cursor, what the open conversation does when deleted, and which local chats
are unsaved. BUG-1467.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-28 01:52:47 +08:00

110 lines
5.0 KiB
TypeScript

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,
);
});