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/20260925020000_chart_subject_typed_columns.sql", import.meta.url); const migrationSql = readFileSync(migrationPath, "utf8"); const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; const schemaPassword = "schema-owner-test-password"; const HASH = "a".repeat(64); function windowsMigrationDirectory(): string { const directory = mkdtempSync(join(tmpdir(), "jyotisha-subject-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("20260925020000_chart_subject_typed_columns.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 lastLine(value: string): string { const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines[lines.length - 1] ?? ""; } function backfillUpdate(): string { const start = migrationSql.indexOf("-- BEGIN chart_subject_backfill"); const end = migrationSql.indexOf("-- END chart_subject_backfill"); assert.ok(start >= 0 && end > start, "backfill markers missing"); const update = migrationSql.slice(start, end).match(/update public\.chart_profiles as subject[\s\S]*?;/)?.[0]; assert.ok(update, "backfill update must be cut from the migration"); return update; } function profileJson(extra: Record = {}): string { return JSON.stringify({ name: "虚构乙", date: "1992-06-15", reportedTime: "07:40", birthTimeSource: "family_exact", birthTimeStatus: "reported", birthPlaceLabel: "虚构港", latitude: 22.3, longitude: 114.1, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "lahiri", chartRelationship: "friend", ...extra, }).replaceAll("'", "''"); } test("chart subject columns, guards, limit and cascaded delete", { skip: docker ? false : "docker unavailable", }, () => { const fixture = startPostgresFixture(); try { applyMigrations(fixture); assert.match(migrationSql, /delete from public\.chart_profiles where role = 'self'/); assert.doesNotMatch(migrationSql, /drop column .*profile/i); const owner = randomUUID(); const otherUser = randomUUID(); fixture.psql(` insert into identity.users (id, name, email, email_verified, email_verified_at) values ('${owner}', 'Subject Owner', '${owner}@example.invalid', true, now()), ('${otherUser}', 'Subject Other', '${otherUser}@example.invalid', true, now()); `); const dirtyId = randomUUID(); fixture.psql(` insert into public.chart_profiles (id, user_id, role, profile) values ('${dirtyId}', '${owner}', 'other', '{"name":"虚构丙","chartRelationship":"family"}'::jsonb); `); assert.equal( fixture.psql(`select birth_date is null and reported_birth_time is null from public.chart_profiles where id = '${dirtyId}'`), "t", ); const completeId = randomUUID(); fixture.psql(` insert into public.chart_profiles (id, user_id, role, profile, name, birth_date, reported_birth_time, latitude, longitude) values ( '${completeId}', '${owner}', 'other', '${profileJson()}'::jsonb, null, null, null, null, null ); `); fixture.psql(backfillUpdate()); const filled = fixture.psql(` select name || '|' || birth_date::text || '|' || to_char(reported_birth_time, 'HH24:MI') || '|' || birth_time_source || '|' || latitude::text || '|' || ayanamsa from public.chart_profiles where id = '${completeId}' `); assert.equal(filled, "虚构乙|1992-06-15|07:40|family_exact|22.3|lahiri"); assert.equal( fixture.psql(`select profile::text like '%chartRelationship%' from public.chart_profiles where id = '${completeId}'`), "t", "jsonb is kept for rollback and is not the reason a relationship column exists", ); for (let index = 0; index < 2; index += 1) { fixture.psql(` insert into public.chart_profiles (id, user_id, role, profile) values ('${randomUUID()}', '${owner}', 'other', '${profileJson({ name: `虚构${index}` })}'::jsonb); `); } assert.throws( () => fixture.psql(` insert into public.chart_profiles (id, user_id, role, profile) values ('${randomUUID()}', '${owner}', 'other', '${profileJson({ name: "虚构超额" })}'::jsonb); `), /subject_limit_reached/, ); assert.throws( () => fixture.psql(` insert into public.chart_profiles (id, user_id, role, profile) values ('${randomUUID()}', '${owner}', 'self', '{}'::jsonb); `), /chart_subject_self_mirror_retired/, ); assert.throws( () => fixture.psql(` update public.chart_profiles set reported_birth_time = '08:01' where id = '${completeId}'; `), /reported_birth_time_is_immutable/, ); assert.throws( () => fixture.psql(` update public.chart_profiles set active_birth_time = '08:02' where id = '${completeId}'; `), /chart_subject_active_birth_is_locked/, ); assert.throws( () => fixture.psql(` update public.chart_profiles set birth_time_status = 'confirmed' where id = '${completeId}'; `), /chart_subject_birth_status_locked/, ); const sessionId = randomUUID(); const reportId = randomUUID(); const requestId = randomUUID(); fixture.psql(` insert into public.chat_sessions (id, user_id, title, theme, messages, chart_profile_id) values ('${sessionId}', '${owner}', '虚构对话', 'general', '[]'::jsonb, '${completeId}'); insert into public.personal_reports ( id, user_id, chart_profile_id, request_id, request_fingerprint, report_type, status, schema_version, presentation_mode, requested_themes, depth, skill_name, skill_version, skill_source_commit, skill_snapshot_sha256 ) values ( '${reportId}', '${owner}', '${completeId}', '${requestId}', '${HASH}', 'personal_full', 'generating', 'report_document.v2', 'default', array['career']::text[], 'standard', 'jyotish-personal-report', '1.0.0', '${"b".repeat(40)}', '${HASH}' ); `); const keeper = randomUUID(); fixture.psql(` insert into public.chat_sessions (id, user_id, title, theme, messages) values ('${keeper}', '${owner}', '本人对话', 'general', '[{"role":"user","text":"hi"}]'::jsonb); `); const usage = fixture.psqlAs( "app_runtime", "app-runtime-test-password", `set role authenticated; select set_config('request.jwt.claim.sub', '${owner}', true); select session_count::text || '|' || report_count::text from public.chart_subject_usage('${completeId}');`, ); assert.equal(lastLine(usage), "1|1"); const hidden = fixture.psqlAs( "app_runtime", "app-runtime-test-password", `set role authenticated; select set_config('request.jwt.claim.sub', '${otherUser}', true); select count(*) from public.chart_profiles where id = '${completeId}';`, ); assert.equal(lastLine(hidden), "0"); assert.throws( () => fixture.psqlAs( "app_runtime", "app-runtime-test-password", `set role authenticated; select set_config('request.jwt.claim.sub', '${otherUser}', true); select public.delete_chart_subject('${completeId}');`, ), /chart_subject_not_found/, ); assert.equal(fixture.psql(`select count(*) from public.chart_profiles where id = '${completeId}'`), "1"); fixture.psqlAs( "app_runtime", "app-runtime-test-password", `set role authenticated; select set_config('request.jwt.claim.sub', '${owner}', true); select public.delete_chart_subject('${completeId}');`, ); assert.equal(fixture.psql(`select count(*) from public.chart_profiles where id = '${completeId}'`), "0"); assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id = '${sessionId}'`), "0"); assert.equal(fixture.psql(`select count(*) from public.personal_reports where id = '${reportId}'`), "0"); assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id = '${keeper}'`), "1"); assert.equal( fixture.psql(`select count(*) from public.chat_sessions where user_id = '${owner}' and (chart_profile_id is null or chart_profile_id = 'self')`), "1", ); } finally { fixture.stop(); } });