324 lines
15 KiB
TypeScript
324 lines
15 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 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();
|
|
}
|
|
});
|