import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); const userId = "83000000-0000-4000-8000-000000000083"; const otherUserId = "83000000-0000-4000-8000-000000000183"; const personId = "83000000-0000-4000-8000-000000000283"; const partnerId = "83000000-0000-4000-8000-000000000383"; const reportId = "83000000-0000-4000-8000-000000000483"; const otherReportId = "83000000-0000-4000-8000-000000000583"; const sessionId = "83000000-0000-4000-8000-000000000683"; const requestId = "83000000-0000-4000-8000-000000000783"; const otherRequestId = "83000000-0000-4000-8000-000000000883"; const fingerprint = "a".repeat(64); const skillHash = "d".repeat(64); function dockerAvailable(): boolean { return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0; } function errorText(error: unknown): string { if (!error || typeof error !== "object") return String(error); const stderr = "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : ""; const stdout = "stdout" in error ? String((error as { stdout?: unknown }).stdout ?? "") : ""; return `${stderr}\n${stdout}\n${String(error)}`; } test("synastry chat binds an owned section, rejects another report, and stays readable after delete", { skip: dockerAvailable() ? false : "docker unavailable on this host", }, () => { const fixture = startPostgresFixture(); try { const migration = spawnSync(process.execPath, [runnerPath], { encoding: "utf8", env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, }); assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`); fixture.psql(` insert into identity.users (id, name, email, email_verified, email_verified_at) values ('${userId}', 'Fictional Synastry Chat', '${userId}@example.invalid', true, now()), ('${otherUserId}', 'Fictional Other', '${otherUserId}@example.invalid', true, now()); update public.profiles set credits = 10 where id = '${userId}'; insert into public.chart_profiles (id, user_id, role, profile) values ('${personId}', '${userId}', 'other', '{}'::jsonb), ('${partnerId}', '${userId}', 'other', '{}'::jsonb); `); const insertReport = (id: string, owner: string, request: string, person: string, partner: string) => ` insert into public.personal_reports ( id, user_id, chart_profile_id, partner_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, subject_low, subject_high, fingerprint_low, fingerprint_high, synastry_rules_version ) values ( '${id}', '${owner}', '${person}', '${partner}', '${request}', '${fingerprint}', 'synastry_full', 'generating', 'synastry_report.v1', 'default', array[]::text[], 'standard', 'jyotish-full-synastry-reading', '1.0.0', '${"e".repeat(40)}', '${skillHash}', '${person}', '${partner}', '${fingerprint}', '${"b".repeat(64)}', 'bde5d615:marriage_match_product_v1' ); `; fixture.psql(insertReport(reportId, userId, requestId, personId, partnerId)); fixture.psql(` update public.personal_reports set status = 'ready', completed_at = now(), report_document = '{"names":{"a":"甲","b":"乙"}}'::jsonb, calculation_hash = '${skillHash}', evidence_hash = '${skillHash}' where id = '${reportId}'; update public.personal_report_jobs set status = 'cancelled', finished_at = now() where user_id = '${userId}' and status = 'queued'; `); fixture.psql(insertReport(otherReportId, otherUserId, otherRequestId, personId, partnerId)); fixture.psql(` insert into public.chat_sessions ( id, user_id, title, theme, messages, session_type, synastry_report_id, synastry_section, synastry_partner_subject_id, chart_profile_id, chart_profile_name, chart_profile_role ) values ( '${sessionId}', '${userId}', '甲 × 乙 · 合盘追问', 'marriage', '[]'::jsonb, 'synastry_report', '${reportId}', 'climate', '${partnerId}', '${personId}', '甲 × 乙', 'other' ); `); assert.throws(() => fixture.psql(` insert into public.chat_sessions ( id, user_id, title, theme, messages, session_type, synastry_report_id, synastry_section, synastry_partner_subject_id ) values ( '83000000-0000-4000-8000-000000000983', '${userId}', '越权', 'marriage', '[]'::jsonb, 'synastry_report', '${otherReportId}', 'climate', '${partnerId}' ); `), (error: unknown) => /synastry_report_not_owned/.test(errorText(error))); assert.throws(() => fixture.psql(` insert into public.chat_sessions ( id, user_id, title, theme, messages, session_type, synastry_report_id, synastry_section, synastry_partner_subject_id ) values ( '83000000-0000-4000-8000-000000001083', '${userId}', '非法节', 'marriage', '[]'::jsonb, 'synastry_report', '${reportId}', 'practices', '${partnerId}' ); `), (error: unknown) => /synastry_section_invalid/.test(errorText(error))); const reserved = fixture.psql(` select reason from public.reserve_consultation_usage( '${userId}', 'chat-83', '${sessionId}', 'model-a', 1 ); `); assert.equal(reserved.includes("session_missing"), false); fixture.psql(`delete from public.personal_reports where id = '${reportId}';`); assert.equal(fixture.psql(` select (synastry_report_id is null)::text || '|' || synastry_section || '|' || session_type from public.chat_sessions where id = '${sessionId}'; `), "true|climate|synastry_report"); assert.equal(fixture.psql(` select reason from public.reserve_consultation_usage( '${userId}', 'chat-83-after-delete', '${sessionId}', 'model-a', 1 ); `), "session_missing"); } finally { fixture.stop(); } });