import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import React from "react"; import { AccountDeletionGate } from "../src/components/account-deletion-gate"; import { AccountDeletionSection } from "../src/components/account-deletion-section"; import { ACCOUNT_DELETION_KEPT_TABLES, accountDeletionRequestErrorCode, formatAccountDeletionDate, isAccountDeletionConfirmation, parseAccountDeletionStatus, readPendingAccountDeletionWith, } from "../src/lib/account-deletion"; import { ACCOUNT_DELETION_MAX_ATTEMPTS, purgeErrorCode, runAccountDeletionTick, } from "../src/lib/account-deletion-worker-core"; import { LEGAL_ENTITY } from "../src/lib/legal-entity"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; // Self-service account deletion with a 7-day cooling-off period (2026-09-30). Object.assign(globalThis, { React }); const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); const migration = read("../supabase/migrations/20260930020000_account_deletion_requests.sql"); test("status parsing, confirmation word, date and error codes", () => { assert.deepEqual(parseAccountDeletionStatus(null), { status: "none" }); assert.deepEqual(parseAccountDeletionStatus({ status: "none" }), { status: "none" }); assert.deepEqual(parseAccountDeletionStatus({ status: "pending", scheduledFor: "not a date" }), { status: "none" }); const pending = parseAccountDeletionStatus({ status: "pending", requestedAt: "2026-09-30T02:00:00Z", scheduledFor: "2026-10-07T02:00:00Z" }); assert.deepEqual(pending, { status: "pending", requestedAt: "2026-09-30T02:00:00.000Z", scheduledFor: "2026-10-07T02:00:00.000Z" }); assert.equal(formatAccountDeletionDate("2026-10-07T02:00:00Z"), "10 月 7 日"); assert.equal(formatAccountDeletionDate("2026-10-06T17:00:00Z"), "10 月 7 日", "Beijing date, not UTC"); assert.equal(isAccountDeletionConfirmation(" 注销 "), true); assert.equal(isAccountDeletionConfirmation("注 销"), false); assert.equal(isAccountDeletionConfirmation(undefined), false); assert.equal(accountDeletionRequestErrorCode("ERROR: account_deletion_admin_account"), "admin_account"); assert.equal(accountDeletionRequestErrorCode("connection refused"), "unavailable"); }); test("the pending-deletion read never locks anyone out on an error", async () => { const ok = { rpc: async () => ({ data: "2026-10-07T02:00:00Z", error: null }) }; assert.equal(await readPendingAccountDeletionWith(ok, "u"), "2026-10-07T02:00:00.000Z"); assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: null }) }, "u"), null); assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: { message: "function does not exist" } }) }, "u"), null); assert.equal(await readPendingAccountDeletionWith({ rpc: async () => { throw new Error("down"); } }, "u"), null); }); test("the kept tables in code and in SQL are the same list, and finance is kept", () => { const sqlList = migration.slice(migration.indexOf("select array["), migration.indexOf("]::text[]")); const sqlTables = [...sqlList.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]); assert.deepEqual(sqlTables, [...ACCOUNT_DELETION_KEPT_TABLES]); // 2026-09-30 合并时加入:反馈与投诉保留(产品决定),联系方式在清除时置空。 assert.match(migration, /'user_feedback'/); assert.match(migration, /update public\.user_feedback set contact = null/); for (const table of ["payment_orders", "credit_transactions", "usage_ledger", "usage_reservations", "user_subscriptions", "birth_time_rectification_billing"]) { assert.ok((ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), table); } for (const table of ["chat_sessions", "chart_profiles", "personal_reports", "synastry_reports", "profiles"]) { assert.ok(!(ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), `${table} is deleted`); } }); test("the migration is add-only, all-or-nothing, and only touches ownership columns", () => { assert.doesNotMatch(migration, /\bdrop\s+(table|column)\b|alter\s+table\s+public\.(?!account_deletion_requests)\w+\s+(drop|alter|rename)/i); assert.match(migration, /create table if not exists public\.account_deletion_requests/); assert.match(migration, /now\(\) \+ interval '7 days'/); // Signed out everywhere on request. assert.match(migration, /delete from identity\.sessions where user_id = \$1/); // Deletes keep retrying across passes; leftovers roll the whole thing back. assert.match(migration, /exception when foreign_key_violation/); assert.match(migration, /raise exception 'account_deletion_purge_incomplete/); // created_by / updated_by rows are operators' configuration, never the user's. assert.match(migration, /att\.attname = 'user_id' or \(cls\.relname = 'profiles' and att\.attname = 'id'\)/); // Tombstone identity: no email, name, login. assert.match(migration, /email = ''deleted\+'' \|\| id::text \|\| ''@deleted\.invalid''/); assert.match(migration, /delete from identity\.accounts where user_id = \$1/); // No charge while pending, enforced in the database too. for (const table of ["usage_reservations", "birth_time_rectification_billing", "credit_transactions"]) { assert.match(migration, new RegExp(`before insert on public\\.${table}`), table); } assert.match(migration, /raise exception 'account_deletion_admin_account'/); }); test("the purge worker completes, skips, counts failures and logs no identifiers", async () => { const logs: string[] = []; const failed: [string, string][] = []; const seen: { limit: number; attempts: number }[] = []; const result = await runAccountDeletionTick({ now: () => new Date("2026-10-08T00:00:00Z"), listDue: async (_now, limit, attempts) => { seen.push({ limit, attempts }); return ["r1", "r2", "r3"]; }, purge: async (id) => (id === "r1" ? { ok: true, status: "completed" } : id === "r2" ? { ok: true, status: "skipped" } : { ok: false, code: "purge_incomplete" }), markFailed: async (id, code) => { failed.push([id, code]); }, log: (line) => logs.push(line), }); assert.deepEqual(result, { due: 3, completed: 1, skipped: 1, failed: 1 }); assert.deepEqual(failed, [["r3", "purge_incomplete"]]); assert.equal(seen[0]?.attempts, ACCOUNT_DELETION_MAX_ATTEMPTS); assert.equal(logs.length, 1); assert.doesNotMatch(logs[0]!, /r1|r2|r3|@/); assert.equal(purgeErrorCode("account_deletion_purge_incomplete: chat_sessions"), "purge_incomplete"); assert.equal(purgeErrorCode("boom"), "purge_failed"); const quiet: string[] = []; await runAccountDeletionTick({ now: () => new Date(), listDue: async () => [], purge: async () => ({ ok: true, status: "completed" }), markFailed: async () => {}, log: (line) => quiet.push(line) }); assert.deepEqual(quiet, [], "an idle tick says nothing"); }); test("every paid route refuses a frozen account before doing work", () => { for (const path of [ "../src/app/api/consult/route.ts", "../src/app/api/reports/route.ts", "../src/app/api/rectification/agent/route.ts", "../src/app/api/rectification/cases/open/route.ts", "../src/app/api/synastry/route.ts", ]) { const source = read(path); assert.match(source, /refuseWhenAccountDeletionPending\(/, path); assert.match(source, /if \(frozen\) return frozen;/, path); } const server = read("../src/lib/account-deletion-server.ts"); assert.match(server, /status: 423/); assert.match(read("../src/instrumentation.ts"), /startAccountDeletionWorker\(\);/); }); test("the settings section explains the rule and only submits after 「注销」 is typed", async () => { const h = createClientLifecycleHarness(); try { await h.render(); const open = h.elements().find((node) => node.tagName === "BUTTON" && node.text.startsWith("注销账号"))!; await h.event(open); const text = h.container.text; assert.match(text, /7 天内重新登录可以撤销/); assert.match(text, /剩余点数和会员权益会一并作废/); assert.match(text, /订单和点数流水按法规保留/); assert.ok(text.includes(LEGAL_ENTITY.contactEmail)); const confirm = () => h.elements().find((node) => node.tagName === "BUTTON" && /确认注销|正在提交/.test(node.text))!; assert.equal(confirm().disabled, true); const input = h.elements().find((node) => node.tagName === "INPUT")!; await h.event(input, "onChange", { target: { value: "注销" } }); assert.equal(confirm().disabled, false); assert.deepEqual(h.errors, []); } finally { await h.close(); } }); test("a pending account sees 「账号注销中」 with the date and a way back", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response(JSON.stringify({ status: "pending", scheduledFor: "2026-10-07T02:00:00Z" }), { status: 200 })) as typeof fetch; const h = createClientLifecycleHarness(); try { await h.render(); await h.idle(); assert.match(h.container.text, /账号注销中/); assert.match(h.container.text, /10 月 7 日 永久删除/); assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "撤销注销")); assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "退出登录")); await h.render(); } finally { globalThis.fetch = originalFetch; await h.close(); } const signedOut = createClientLifecycleHarness(); let calls = 0; globalThis.fetch = (async () => { calls += 1; return new Response("{}"); }) as typeof fetch; try { await signedOut.render(); await signedOut.idle(); assert.equal(calls, 0, "no request before sign-in"); assert.equal(signedOut.container.text, ""); } finally { globalThis.fetch = originalFetch; await signedOut.close(); } });