Rewire Phase 1 to call the user-service POST /api/v1/users/onboarding-reset A2A endpoint (targeting by clerk_id) instead of the per-user authenticated DELETE route, which could not reach all users. A 404 from user-service means the user has no profile (backend-only) — a clean skip that does not block Phase 2. Phase 2 (backend ledger reset via resetOnboardingLedger) now runs ONLY after Phase 1 succeeds or is skipped; a non-404 pref error blocks the ledger for that user to avoid inconsistent state. Triple guard (NODE_ENV=staging, ONBOARDING_BULK_RESET_ALLOWED=true, --confirm) and aggregate-only output are preserved. Add source-structure assertions for the CLI contract.
177 lines
9.3 KiB
TypeScript
177 lines
9.3 KiB
TypeScript
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);
|