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"; // Self-service account deletion (2026-09-30): request signs out and freezes, // cancel restores, purge deletes personal content and keeps de-identified // finance rows. Needs Docker (test:db). const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); const userId = "91000000-0000-4000-8000-000000000001"; const otherId = "91000000-0000-4000-8000-000000000002"; function dockerAvailable(): boolean { return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0; } test("account deletion: request, frozen charges, cancel, purge with kept finance rows", { 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}`); assert.match(migration.stdout, /applied 20260930020000_account_deletion_requests\.sql/); fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` insert into identity.users (id, name, email, email_verified, email_verified_at) values ('${userId}', 'Deletion User', 'deletion-user@example.com', true, now()), ('${otherId}', 'Other User', 'deletion-other@example.com', true, now()); insert into identity.accounts (id, account_id, provider_id, user_id, password) values ('92000000-0000-4000-8000-000000000001', 'deletion-user@example.com', 'credential', '${userId}', 'password-hash'); insert into identity.sessions (id, token, user_id, expires_at) values ('92000000-0000-4000-8000-000000000002', 'deletion-session-token', '${userId}', now() + interval '1 day'); `); fixture.psql(` update public.profiles set credits = 12, name = 'Deletion User', birth_date = '1990-01-02' where id = '${userId}'; insert into public.chat_sessions (user_id, title, theme, messages) values ('${userId}', 'Mine', 'general', '[]'::jsonb), ('${otherId}', 'Theirs', 'general', '[]'::jsonb); insert into public.chart_profiles (user_id, role, profile) values ('${userId}', 'other', '{}'::jsonb); insert into public.synastry_reports (user_id, partner_name, report) values ('${userId}', 'Partner', '{}'::jsonb); insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id) values ('${userId}', 'redeem', 12, 12, 'deletion-kept-credit'); `); // Request: pending, signed out everywhere. const requested = JSON.parse(fixture.psql(`select public.request_account_deletion('${userId}')::text;`).trim().split("\n").pop()!); assert.equal(requested.status, "pending"); assert.equal(fixture.psql(`select count(*) from identity.sessions where user_id = '${userId}';`).trim(), "0"); // Frozen: a debit is refused, a credit (refund) is not. assert.throws(() => fixture.psql(` insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id) values ('${userId}', 'consume', -1, 11, 'deletion-refused-debit'); `), /account_deletion_pending/); // Cancel within the window restores; request again for the purge. assert.match(fixture.psql(`select public.cancel_account_deletion('${userId}');`), /\bt\b/); fixture.psql(`select public.request_account_deletion('${userId}');`); const requestId = fixture.psql(`select id from public.account_deletion_requests where user_id = '${userId}' and status = 'pending';`).trim(); // Not due yet: skipped, nothing touched. assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /not_due/); fixture.psql(`update public.account_deletion_requests set requested_at = now() - interval '8 days', scheduled_for = now() - interval '1 day' where id = '${requestId}';`); const purged = fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`); assert.match(purged, /"status": ?"completed"/); const state = JSON.parse(fixture.psql(` select jsonb_build_object( 'chatMine', (select count(*) from public.chat_sessions where user_id = '${userId}'), 'chatOther', (select count(*) from public.chat_sessions where user_id = '${otherId}'), 'chartProfiles', (select count(*) from public.chart_profiles where user_id = '${userId}'), 'synastry', (select count(*) from public.synastry_reports where user_id = '${userId}'), 'profiles', (select count(*) from public.profiles where id = '${userId}'), 'keptCredit', (select count(*) from public.credit_transactions where user_id = '${userId}'), 'identityEmail', (select email from identity.users where id = '${userId}'), 'identityName', (select name from identity.users where id = '${userId}'), 'authEmail', (select email from auth.users where id = '${userId}'), 'accounts', (select count(*) from identity.accounts where user_id = '${userId}'), 'status', (select status from public.account_deletion_requests where id = '${requestId}') )::text; `).trim()); assert.deepEqual(state, { chatMine: 0, chatOther: 1, chartProfiles: 0, synastry: 0, profiles: 0, keptCredit: 1, identityEmail: `deleted+${userId}@deleted.invalid`, identityName: "已注销用户", authEmail: `deleted+${userId}@deleted.invalid`, accounts: 0, status: "completed", }); // Idempotent: a second purge of the same request is a no-op. assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /skipped/); } finally { fixture.stop?.(); } });