import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { COMPLETION_EVENT_TYPES, COMPLETION_EVENT_TYPE_ALIASES, SNAPSHOT_EVENT_TYPE_ALIASES, resetOnboardingPreferences, defaultOnboardingData, extractOnboardingData, ONBOARDING_LEDGER_QUERY_TYPES, } from "../src/events/onboarding-ledger.js"; /** * Focused tests for onboarding reset helpers (pure — no DB/network). * Covers: per-user reset state (preferences reset to default, unrelated keys * preserved), ledger type scope (only completion types deleted, snapshots * preserved), and bulk guard behavior (assertStagingGuard invariants). * * The async resetOnboardingLedger is exercised in integration; here we assert * its type-scope contract via the exported COMPLETION_EVENT_TYPES set, which is * exactly the WHERE clause it builds. */ // ── 1. resetOnboardingPreferences: resets onboarding to default v3 ────────── { const preferences = { onboarding: { schema_version: 3, revision: 5, status: "completed", completed_at: "2026-07-10T00:00:00.000Z", progress: { stage: "done", branch_index: 3, updated_at: "2026-07-10T00:00:00.000Z" }, consent: { privacy_accepted: true, accepted_at: "2026-07-09T00:00:00.000Z", terms_version: "2026-07" }, profile: { intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" }, responses: { career_barriers: ["x"], desired_outcomes: ["y"], current_situation: null, target_milestone: null, weekly_time_commitment: "5-10", target_role: "Intern", target_field: "tech", venture_industry: null, experience_level: "0-2", work_context: "audience" }, import: { method: "manual", status: "not_started", resume_id: null, resume_filename: null, resume_summary: null, linkedin_profile_id: null, linkedin_url: null }, access_choice: "trial", qx_estimate: 42, }, interview_preferences: { focus_areas: ["behavioral"] }, resume_preferences: { target_title: "Data Scientist" }, target_roles: ["Product Intern"], }; const reset = resetOnboardingPreferences(preferences); const next = extractOnboardingData(reset.onboarding); // Onboarding blob is back to defaults. assert.equal(next.schema_version, 3); assert.equal(next.revision, 0, "revision resets to 0"); assert.equal(next.status, "in_progress", "status reverts to in_progress"); assert.equal(next.completed_at, null, "completed_at is cleared"); assert.equal(next.progress.stage, "consent", "progress stage resets to consent"); assert.equal(next.access_choice, null); assert.equal(next.qx_estimate, null); assert.deepEqual(next, defaultOnboardingData()); // Unrelated preference keys are preserved verbatim. assert.deepEqual(reset.interview_preferences, { focus_areas: ["behavioral"] }); assert.deepEqual(reset.resume_preferences, { target_title: "Data Scientist" }); assert.deepEqual(reset.target_roles, ["Product Intern"]); } // ── 2. resetOnboardingPreferences: idempotent on already-default prefs ───── { const empty = {}; const reset = resetOnboardingPreferences(empty); assert.deepEqual(reset.onboarding, defaultOnboardingData()); // Original object is not mutated. assert.equal(Object.keys(empty).length, 0, "original preferences not mutated"); } // ── 3. Ledger reset scope: completion aliases (both forms) + completion snapshots // resetOnboardingLedger builds: DELETE FROM grow_events WHERE userId = ? AND ( // type IN (COMPLETION_EVENT_TYPE_ALIASES) // OR (type IN (SNAPSHOT_EVENT_TYPE_ALIASES) AND payload-has-completion) // ). This pins the contract: (a) all completion aliases — dotted AND underscore — // are deleted so a legacy underscore completion row can't keep the gate shut; // (b) snapshot types are NOT blanket completion types (only completion-bearing // snapshots are deleted via the jsonb predicate); (c) intermediate snapshots are // preserved because they never satisfy the completion predicate. { // (a) Completion aliases cover BOTH dotted and underscore forms — 6 total. assert.equal(COMPLETION_EVENT_TYPE_ALIASES.length, 6, "3 dotted + 3 underscore completion aliases"); for (const dotted of Object.keys(COMPLETION_EVENT_TYPES)) { const underscore = dotted.replaceAll(".", "_"); assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(dotted), `${dotted} in reset scope`); assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(underscore), `${underscore} alias in reset scope`); } // (b) Snapshot types are handled in a SEPARATE phase, never as completion types. assert.equal(COMPLETION_EVENT_TYPES["onboarding.snapshot.saved"], undefined, "snapshot must never be a blanket completion type"); assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding.snapshot.saved"), true, "dotted snapshot alias is in the snapshot reset phase"); assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding_snapshot_saved"), true, "underscore snapshot alias is in the snapshot reset phase"); // (c) The reset completion set is a strict subset of the status-query list: // status reads ALL aliases + snapshots; reset deletes only completions and // completion-bearing snapshots, preserving intermediate saves. for (const alias of COMPLETION_EVENT_TYPE_ALIASES) { assert.ok((ONBOARDING_LEDGER_QUERY_TYPES as readonly string[]).includes(alias), `${alias} is also queryable for status`); } } // ── 4. Bulk guard behavior: real spawn of assertStagingGuard ─────────────── // Spawns the actual CLI with controlled env and asserts exit code 2 (abort) // for each independent failure leg. We only assert abort cases — the // all-flags-pass case would proceed past the guard and hit the network/DB. { const script = "scripts/onboarding-bulk-reset.ts"; // Leg 1: production env (even with all other flags) must abort. const prod = spawnSync("npx", ["tsx", script, "--confirm"], { env: { ...process.env, NODE_ENV: "production", ONBOARDING_BULK_RESET_ALLOWED: "true" }, encoding: "utf8", }); assert.equal(prod.status, 2, "production must abort even with all flags"); assert.match(prod.stderr, /NODE_ENV must be "staging"/, "production abort names the env failure"); // Leg 2: staging + allowed but missing --confirm must abort. const noConfirm = spawnSync("npx", ["tsx", script], { env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "true" }, encoding: "utf8", }); assert.equal(noConfirm.status, 2, "staging without --confirm must abort"); assert.match(noConfirm.stderr, /--confirm/, "missing-confirm abort names the confirm failure"); // Leg 3: staging + confirm but no opt-in flag must abort. const noFlag = spawnSync("npx", ["tsx", script, "--confirm"], { env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "false" }, encoding: "utf8", }); assert.equal(noFlag.status, 2, "staging without opt-in flag must abort"); assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure"); } // ── 5. Bulk CLI structure: A2A endpoint + Phase 2 gating (source read) ────── // The CLI's network/DB behavior is exercised in integration. Here we assert // the structural contract by reading the script source directly: (a) it calls // the user-service A2A onboarding-reset endpoint (not the backend DELETE // route), (b) it authenticates with a2aAllowedKey (not serviceToken), (c) it // treats 404 as a skip, and (d) it gates the backend ledger reset on Phase 1 // success-or-skip so a pref error never leaves inconsistent state. { const here = dirname(fileURLToPath(import.meta.url)); const src = readFileSync(join(here, "onboarding-bulk-reset.ts"), "utf8"); // (a) Calls the user-service A2A onboarding-reset endpoint by path. assert.match(src, /\/api\/v1\/users\/onboarding-reset/, "CLI must call the user-service A2A reset endpoint"); // Must NOT call the backend DELETE /users/onboarding route (old impersonation path). assert.doesNotMatch(src, /method:\s*["']DELETE["']/, "CLI must not use the backend DELETE route"); // (b) Authenticates with the A2A key, not the per-user service token. assert.match(src, /a2aAllowedKey/, "CLI must use config.a2aAllowedKey for A2A auth"); assert.doesNotMatch(src, /serviceToken/, "CLI must not reference serviceToken (per-user impersonation path)"); // (c) Treats a 404 from user-service as a documented skip (backend-only user). assert.match(src, /status === 404/, "CLI must detect 404 from user-service"); assert.match(src, /backend-only/, "CLI must document 404 as backend-only skip"); // (d) Phase 2 ledger reset is gated on Phase 1 success-or-skip. assert.match(src, /!pref\.ok && !pref\.skipped/, "CLI must skip ledger when pref errored (not ok, not skipped)"); assert.match(src, /ledgerBlocked/, "CLI must count pref-blocked ledger skips"); // (e) Calls resetOnboardingLedger directly (not via HTTP). assert.match(src, /import \{[^}]*resetOnboardingLedger[^}]*\} from/, "CLI must import resetOnboardingLedger directly"); // (f) Aggregate-only output — no per-user email/PII in the loop. assert.doesNotMatch(src, /u\.email/, "CLI must not emit per-user email (PII)"); } console.log("onboarding-reset tests passed"); process.exit(0);