Files
Jyotisha/frontend/tests/database-chart-subject.test.ts
T
jesse-ux ab6c55f822 feat: keep up to five chart subjects on typed columns
Chart, ephemeris, reports, daily language, consult, and synastry read birth data only through resolveSubjectBirth. The current person is a request parameter outside Home(). /people replaces the settings chart pane, and deleting someone removes that person's chats and reports.
2026-09-25 04:19:05 +08:00

250 lines
10 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/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<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("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, unknown> = {}): 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();
}
});