Files
Jyotisha/frontend/tests/rectification-session-title-repair-migration.test.ts
T
Jesse_ChenandClaude Opus 5 d6c359b205 fix(db): 把校正标题/活跃时间修补搬成迁移
BUG-699 / BUG-704 的数据修补原先只存在于
frontend/scripts/repair-rectification-session-titles.mjs,需要
SCHEMA_DATABASE_URL 才能跑,产品负责人没有任何按钮能执行它,所以那批
错日期的会话标题一直没修。

脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成的
`Migrate Staging Database` 按钮:

- 新增 20260916020000_rectification_session_title_repair.sql,两段 update
  逐字取自脚本(合同测试比对,改了哪边都会红);权限守卫沿用
  20260915010000 的 schema_owner 写法。
- 幂等:改完之后两段 where 都不再匹配同一行,重复应用影响 0 行。
- 各自 get diagnostics + raise notice 打出行数;db-migrate.mjs 加 notice
  转发,否则 node-postgres 会把 NOTICE 丢掉,迁移日志里一个数字都看不到。
- 脚本降级为只读核对工具:--apply 改为报错并指向迁移;导入不再连库。

生产停在 7b620c7a(没有 use-rectification-surface.ts,标题固定且不写库),
两段 where 自然匹配 0 行,是预期内的 no-op。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 01:35:01 +00:00

84 lines
3.3 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
ACTIVITY_APPLY_SQL,
TITLE_APPLY_SQL,
TITLE_MATCH_SQL,
} from "../scripts/repair-rectification-session-titles.mjs";
const filename = "20260916020000_rectification_session_title_repair.sql";
const migration = readFileSync(
new URL(`../supabase/migrations/${filename}`, import.meta.url),
"utf8",
);
// Only trailing whitespace differs: the script builds `where ${TITLE_MATCH_SQL}`,
// which leaves a space before the newline that no .sql file should carry.
const normalize = (value: string) => value.replace(/[ \t]+$/gm, "").trim();
test("repair migration carries the checker's SQL verbatim", () => {
for (const [name, sql] of [
["TITLE_MATCH_SQL", TITLE_MATCH_SQL],
["TITLE_APPLY_SQL", TITLE_APPLY_SQL],
["ACTIVITY_APPLY_SQL", ACTIVITY_APPLY_SQL],
] as const) {
assert.ok(
normalize(migration).includes(normalize(sql)),
`${name} must be copied into ${filename} without being rewritten`,
);
}
});
test("repair migration keeps the guards that make it safe to re-run", () => {
// Asia/Shanghai, not UTC: the wall-clock date is what the user reads (BUG-699).
assert.match(migration, /at time zone 'Asia\/Shanghai'/);
// Never guess a date when created_at is missing.
assert.match(migration, /when created_at is null then '生时校正'/);
// Monotonic: updated_at only moves forward (BUG-704).
assert.match(
migration,
/session\.updated_at < coalesce\(turns\.last_turn_at, case_row\.last_activity_at\)/,
);
// Only schema_owner may run it, like every other business migration.
assert.match(migration, /rectification_session_title_repair_requires_schema_owner/);
assert.match(migration, /^begin;[\s\S]*^commit;$/m);
});
test("repair migration reports its row counts so staging logs show a number", () => {
assert.match(migration, /get diagnostics repaired_titles = row_count;/);
assert.match(migration, /get diagnostics refreshed_activity = row_count;/);
assert.match(migration, /raise notice '[^']*repaired_titles=%', repaired_titles;/);
assert.match(migration, /raise notice '[^']*refreshed_activity=%', refreshed_activity;/);
// The counts only reach the log because the migrator forwards notices.
const migrator = readFileSync(
new URL("../scripts/db-migrate.mjs", import.meta.url),
"utf8",
);
assert.match(migrator, /client\.on\("notice"/);
});
test("repair migration sorts last and is not copied into the identity foundation", () => {
assert.ok(filename > "20260916010000_consultation_session_capacity.sql");
assert.match(filename, /^\d{14}_[a-z0-9_]+\.sql$/);
assert.equal(
existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
test("the checker no longer writes; --apply points at the migration", () => {
const checker = readFileSync(
new URL("../scripts/repair-rectification-session-titles.mjs", import.meta.url),
"utf8",
);
assert.match(checker, /--apply has been removed/);
assert.match(checker, new RegExp(filename.replace(/\./g, "\\.")));
// Importing it must not have opened a connection.
assert.match(checker, /invokedPath === import\.meta\.url/);
});