Files
Jyotisha/frontend/tests/database-rectification-session-title-result.test.ts
T
jesse-ux a94a1d6748 fix(sessions): keep rectification result titles when a new chat opens
The case insert trigger writes the result time, then the existing open persist sends the minted title back. A before-update guard keeps that result unless the user chose a non-automatic name, and a new session reads the stored title into the sidebar.
2026-09-25 02:04:56 +08:00

344 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const migrationPath = new URL("../supabase/migrations/20260925010000_rectification_session_title_result.sql", import.meta.url);
const migrationSql = readFileSync(migrationPath, "utf8");
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const EN_DASH = "\u2013";
const schemaPassword = "schema-owner-test-password";
function windowsMigrationDirectory(): string {
// Git symlinks in frontend/db/migrations are plain relative-path files on
// Windows, so the dual-directory scan reports a duplicate filename. Linux
// skips those symlinks (Dirent.isFile() is false). This copy keeps one real
// SQL file per name and still runs scripts/db-migrate.mjs.
const directory = mkdtempSync(join(tmpdir(), "jyotisha-title-migrations-"));
const seen = new Map<string, Buffer>();
for (const relative of ["../db/migrations", "../supabase/migrations"]) {
const source = fileURLToPath(new URL(relative, import.meta.url));
for (const name of readdirSync(source)) {
if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue;
const bytes = readFileSync(join(source, name));
if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue;
const previous = seen.get(name);
if (previous) {
assert.equal(bytes.equals(previous), true, `${name} mirrors must match`);
continue;
}
seen.set(name, bytes);
writeFileSync(join(directory, name), bytes);
}
}
assert.ok(seen.has("20260925010000_rectification_session_title_result.sql"));
return directory;
}
function applyMigrations(fixture: PostgresFixture): void {
const env = {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword),
};
const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env });
if (first.status === 0) return;
const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`;
assert.match(detail, /duplicate migration filename/, detail);
const directory = windowsMigrationDirectory();
try {
const second = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: { ...env, MIGRATIONS_DIRECTORY: directory },
});
assert.equal(second.status, 0, second.stderr || second.stdout);
} finally {
rmSync(directory, { force: true, recursive: true });
}
}
function backfillUpdate(): string {
const start = migrationSql.indexOf("-- BEGIN rectification_session_title_backfill");
const end = migrationSql.indexOf("-- END rectification_session_title_backfill");
assert.ok(start >= 0 && end > start, "backfill markers missing");
const block = migrationSql.slice(start, end);
const update = block.match(/update public\.chat_sessions as session[\s\S]*?;/)?.[0];
assert.ok(update, "backfill update must be cut from the migration, not copied");
assert.doesNotMatch(update, /updated_at\s*=/);
assert.match(update, /public\.rectification_session_title\(/);
assert.match(update, /public\.rectification_session_title_is_automatic\(/);
return update;
}
function utf8Hex(value: string): string {
return Buffer.from(value, "utf8").toString("hex");
}
function titleHex(fixture: PostgresFixture, sessionId: string): string {
return fixture.psql(
`select encode(convert_to(title, 'UTF8'), 'hex') from public.chat_sessions where id = '${sessionId}'`,
);
}
function identityFingerprint(fixture: PostgresFixture, sessionId: string): string {
return fixture.psql(
`select pinned::text || '|' || messages::text || '|' || updated_at::text from public.chat_sessions where id = '${sessionId}'`,
);
}
function countUpdate(fixture: PostgresFixture, sql: string): string {
const output = fixture.psqlScriptAs("schema_owner", schemaPassword, `
drop table if exists backfill_row_count;
create temp table backfill_row_count(n integer);
do $count$
declare n integer;
begin
${sql}
get diagnostics n = row_count;
insert into backfill_row_count values (n);
end
$count$;
select n from backfill_row_count;
`);
const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return lines[lines.length - 1] ?? "";
}
test("rectification session titles follow the result and do not bump updated_at", {
skip: docker ? false : "docker unavailable",
}, () => {
const fixture = startPostgresFixture();
try {
applyMigrations(fixture);
assert.doesNotMatch(migrationSql, /updated_at\s*=/);
const call = (sql: string) => fixture.psqlAs("schema_owner", schemaPassword, `select ${sql}`);
assert.equal(
call(`public.rectification_session_title('04:00'::time, '05:07'::time, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`),
"生时校正 · 05:07",
);
assert.equal(
call(`public.rectification_session_title('06:30'::time, null, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`),
"生时校正 · 06:30",
);
assert.equal(
call(`public.rectification_session_title(null, null, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`),
`生时校正 · 05:00${EN_DASH}05:15`,
);
assert.equal(
call(`public.rectification_session_title(null, null, '{"start_time":"25:00","end_time":"05:15"}'::jsonb)`),
"生时校正",
);
assert.equal(
call(`public.rectification_session_title(null, null, '{}'::jsonb)`),
"生时校正",
);
const volatility = fixture.psql(`
select proname || '=' || provolatile::text
from pg_proc
where pronamespace = 'public'::regnamespace
and proname in ('rectification_session_title', 'rectification_session_title_is_automatic')
order by proname
`);
assert.equal(
volatility,
"rectification_session_title=i\nrectification_session_title_is_automatic=i",
);
const bodies = fixture.psql(`
select string_agg(prosrc, ' ')
from pg_proc
where pronamespace = 'public'::regnamespace
and proname in ('rectification_session_title', 'rectification_session_title_is_automatic')
`);
assert.doesNotMatch(bodies, /\bfrom\b/i);
assert.equal(
call(`public.rectification_session_title_is_automatic('生时校正 · 05:00-05:15')`),
"f",
);
assert.equal(call(`public.rectification_session_title_is_automatic('我的校正')`), "f");
const triggerDef = fixture.psql(`
select pg_get_triggerdef(oid)
from pg_trigger
where tgname = 'agentic_rectification_cases_title_from_result'
and not tgisinternal
`);
assert.match(triggerDef, /AFTER INSERT OR UPDATE OF accepted_time, confirmed_time, candidate_range/i);
assert.doesNotMatch(triggerDef, /last_activity_at/);
assert.doesNotMatch(triggerDef, /updated_at/);
const touchDef = fixture.psql(`
select pg_get_triggerdef(oid)
from pg_trigger
where tgname = 'agentic_rectification_cases_touch_chat_session'
and not tgisinternal
`);
assert.match(touchDef, /UPDATE OF last_activity_at/i);
assert.doesNotMatch(touchDef, /accepted_time/);
const user = randomUUID();
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`insert into identity.users(id,name,email,email_verified) values('${user}','Fictional title','${user}@example.invalid',true)`,
);
const insertSession = (
id: string,
title: string,
sessionType: string,
updatedAt: string,
pinned: boolean,
message: string,
) => {
fixture.psql(`
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, pinned, updated_at)
values (
'${id}', '${user}', '${title}', 'general', '${sessionType}',
'[{"role":"user","text":"${message}"}]'::jsonb, ${pinned}, '${updatedAt}'
)
`);
};
const insertCase = (
sessionId: string,
accepted: string | null,
confirmed: string | null,
start: string,
end: string,
) => {
const id = randomUUID();
const acceptedSql = accepted ? `'${accepted}'::time` : "null";
const confirmedSql = confirmed ? `'${confirmed}'::time` : "null";
fixture.psql(`
insert into public.agentic_rectification_cases (
id, user_id, session_id, status, skill_name, skill_version,
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range,
accepted_time, confirmed_time
) values (
'${id}', '${user}', '${sessionId}', 'candidate_ready',
'jyotish-birth-time-rectification', '9.0.0', '${"a".repeat(64)}',
'{"birth_date":"2000-01-01"}'::jsonb,
'{"start_time":"${start}","end_time":"${end}"}'::jsonb,
${acceptedSql}, ${confirmedSql}
)
`);
return id;
};
const adoptSession = randomUUID();
insertSession(adoptSession, "生时校正", "birth_time_rectification", "2026-09-11 08:00:00+00", true, "adopt");
const beforeAdoptInsert = identityFingerprint(fixture, adoptSession);
const adoptCase = insertCase(adoptSession, null, null, "05:00", "05:15");
assert.equal(identityFingerprint(fixture, adoptSession), beforeAdoptInsert);
assert.equal(titleHex(fixture, adoptSession), utf8Hex(`生时校正 · 05:00${EN_DASH}05:15`));
const beforeAdopt = identityFingerprint(fixture, adoptSession);
fixture.psql(`update public.agentic_rectification_cases set accepted_time = '05:07'::time where id = '${adoptCase}'`);
assert.equal(titleHex(fixture, adoptSession), utf8Hex("生时校正 · 05:07"));
assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt);
const clientRewrite = fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
`set role authenticated;
select set_config('request.jwt.claim.sub', '${user}', true);
update public.chat_sessions set title = '生时校正' where id = '${adoptSession}' returning id::text;`,
);
assert.match(clientRewrite, new RegExp(adoptSession), clientRewrite);
assert.equal(titleHex(fixture, adoptSession), utf8Hex("生时校正 · 05:07"));
assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt);
const clientRename = fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
`set role authenticated;
select set_config('request.jwt.claim.sub', '${user}', true);
update public.chat_sessions set title = '孩子校正' where id = '${adoptSession}' returning id::text;`,
);
assert.match(clientRename, new RegExp(adoptSession), clientRename);
assert.equal(titleHex(fixture, adoptSession), utf8Hex("孩子校正"));
assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt);
const narrowSession = randomUUID();
insertSession(narrowSession, "生时校正", "birth_time_rectification", "2026-09-12 08:00:00+00", false, "narrow");
const narrowCase = insertCase(narrowSession, null, null, "05:00", "05:15");
const beforeNarrow = identityFingerprint(fixture, narrowSession);
fixture.psql(`
update public.agentic_rectification_cases
set candidate_range = '{"start_time":"05:02","end_time":"05:08"}'::jsonb
where id = '${narrowCase}'
`);
assert.equal(titleHex(fixture, narrowSession), utf8Hex(`生时校正 · 05:02${EN_DASH}05:08`));
assert.equal(identityFingerprint(fixture, narrowSession), beforeNarrow);
const renamedSession = randomUUID();
insertSession(renamedSession, "生时校正", "birth_time_rectification", "2026-09-13 08:00:00+00", true, "renamed-live");
const renamedCase = insertCase(renamedSession, null, null, "08:00", "08:10");
fixture.psql(`update public.chat_sessions set title = '我的校正' where id = '${renamedSession}'`);
const beforeRenameAdopt = identityFingerprint(fixture, renamedSession);
fixture.psql(`update public.agentic_rectification_cases set accepted_time = '08:08'::time where id = '${renamedCase}'`);
assert.equal(titleHex(fixture, renamedSession), utf8Hex("我的校正"));
assert.equal(identityFingerprint(fixture, renamedSession), beforeRenameAdopt);
const touchSession = randomUUID();
insertSession(touchSession, "生时校正", "birth_time_rectification", "2026-09-10 08:00:00+00", false, "touch");
const touchCase = insertCase(touchSession, null, null, "04:10", "04:20");
const titleBeforeTouch = titleHex(fixture, touchSession);
fixture.psql(`
update public.agentic_rectification_cases
set last_activity_at = '2026-09-20 12:00:00+00'
where id = '${touchCase}'
`);
assert.equal(titleHex(fixture, touchSession), titleBeforeTouch);
assert.equal(
fixture.psql(`
select (session.updated_at = case_row.last_activity_at)::text
from public.chat_sessions as session
join public.agentic_rectification_cases as case_row on case_row.session_id = session.id
where session.id = '${touchSession}'
`),
"true",
);
fixture.psql("alter table public.agentic_rectification_cases disable trigger agentic_rectification_cases_title_from_result");
const rows = [
{ key: "confirmed", title: "生时校正", accepted: "04:00", confirmed: "05:07", start: "05:00", end: "05:15", expected: "生时校正 · 05:07", pinned: true, at: "2026-09-01 01:00:00+00" },
{ key: "accepted", title: "生时校正", accepted: "06:30", confirmed: null, start: "05:00", end: "05:15", expected: "生时校正 · 06:30", pinned: false, at: "2026-09-02 01:00:00+00" },
{ key: "range", title: "生时校正", accepted: null, confirmed: null, start: "05:00", end: "05:15", expected: `生时校正 · 05:00${EN_DASH}05:15`, pinned: true, at: "2026-09-03 01:00:00+00" },
{ key: "old-clock", title: "9月17日 · 生时校正 09:35", accepted: null, confirmed: null, start: "04:10", end: "04:20", expected: `生时校正 · 04:10${EN_DASH}04:20`, pinned: false, at: "2026-09-04 01:00:00+00" },
{ key: "old-plain", title: "9月17日 · 生时校正", accepted: "07:07", confirmed: null, start: "01:00", end: "02:00", expected: "生时校正 · 07:07", pinned: true, at: "2026-09-05 01:00:00+00" },
{ key: "renamed", title: "我的校正", accepted: "08:08", confirmed: "08:09", start: "08:00", end: "08:10", expected: "我的校正", pinned: false, at: "2026-09-06 01:00:00+00" },
] as const;
const ids = new Map<string, string>();
for (const row of rows) {
const id = randomUUID();
ids.set(row.key, id);
insertSession(id, row.title, "birth_time_rectification", row.at, row.pinned, row.key);
insertCase(id, row.accepted, row.confirmed, row.start, row.end);
}
const orphan = randomUUID();
insertSession(orphan, "生时校正", "birth_time_rectification", "2026-09-07 01:00:00+00", true, "orphan");
const consultation = randomUUID();
insertSession(consultation, "生时校正", "consultation", "2026-09-08 01:00:00+00", false, "consultation");
const watched = [...ids.values(), orphan, consultation];
const before = new Map(watched.map((id) => [id, identityFingerprint(fixture, id)]));
const update = backfillUpdate();
assert.equal(countUpdate(fixture, update), "5");
for (const row of rows) {
const id = ids.get(row.key);
assert.ok(id);
assert.equal(titleHex(fixture, id), utf8Hex(row.expected), row.key);
assert.equal(identityFingerprint(fixture, id), before.get(id), row.key);
}
assert.equal(titleHex(fixture, orphan), utf8Hex("生时校正"));
assert.equal(titleHex(fixture, consultation), utf8Hex("生时校正"));
assert.equal(identityFingerprint(fixture, orphan), before.get(orphan));
assert.equal(identityFingerprint(fixture, consultation), before.get(consultation));
assert.equal(countUpdate(fixture, update), "0");
fixture.psql("alter table public.agentic_rectification_cases enable trigger agentic_rectification_cases_title_from_result");
} finally {
fixture.stop();
}
});