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