feat(onboarding): persist resettable user state
This commit is contained in:
126
scripts/onboarding-bulk-reset.ts
Executable file
126
scripts/onboarding-bulk-reset.ts
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Staging-only bulk onboarding reset CLI.
|
||||
*
|
||||
* Enumerates every user in the backend `users` table (the Clerk-mirrored
|
||||
* enrollment source) and resets each user's onboarding by calling the
|
||||
* authenticated DELETE /users/onboarding route via the service-token path.
|
||||
* This reuses the exact per-user reset logic end-to-end (preferences reset +
|
||||
* backend ledger deletion) — the CLI never re-implements it.
|
||||
*
|
||||
* GUARDS — all three must hold or the script aborts before any network call:
|
||||
* 1. NODE_ENV=staging (refuses production/development)
|
||||
* 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag)
|
||||
* 3. --confirm on the CLI (typed acknowledgement)
|
||||
*
|
||||
* Usage:
|
||||
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
|
||||
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
|
||||
*
|
||||
* Output: one audit line per user (ok / error / skipped) and a summary.
|
||||
*
|
||||
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
|
||||
* staging-only operations tool; bulk reset must never be exposed as a public
|
||||
* endpoint. Per-user reset is the authenticated DELETE route.
|
||||
*/
|
||||
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { users } from "../src/db/schema.js";
|
||||
import { config } from "../src/config.js";
|
||||
import { log } from "../src/log.js";
|
||||
|
||||
type ResetOutcome =
|
||||
| { userId: string; ok: true; ledgerRowsDeleted: number }
|
||||
| { userId: string; ok: false; error: string; status: number };
|
||||
|
||||
function assertStagingGuard(argv: string[]): void {
|
||||
const isStaging = config.nodeEnv === "staging";
|
||||
const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true";
|
||||
const confirmed = argv.includes("--confirm");
|
||||
|
||||
const failures: string[] = [];
|
||||
if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`);
|
||||
if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\"");
|
||||
if (!confirmed) failures.push("--confirm argument is required");
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — guard checks failed:");
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
console.error("");
|
||||
console.error("This script is staging-only and will reset onboarding for EVERY user.");
|
||||
console.error("Re-run with all guards satisfied to proceed.");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function backendUrl(): string {
|
||||
const host = process.env.BACKEND_HOST ?? "127.0.0.1";
|
||||
const port = config.port;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
async function resetOne(baseUrl: string, serviceToken: string, userId: string): Promise<ResetOutcome> {
|
||||
const res = await fetch(`${baseUrl}/users/onboarding`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { userId, ok: false, error: text || res.statusText, status: res.status };
|
||||
}
|
||||
const body = (await res.json()) as { ledgerRowsDeleted?: number };
|
||||
return { userId, ok: true, ledgerRowsDeleted: body.ledgerRowsDeleted ?? 0 };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertStagingGuard(process.argv.slice(2));
|
||||
|
||||
const serviceToken = config.serviceToken;
|
||||
if (!serviceToken) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — SERVICE_TOKEN is not configured");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const baseUrl = backendUrl();
|
||||
const allUsers = await db.select({ id: users.id, email: users.email }).from(users).orderBy(asc(users.id));
|
||||
|
||||
console.log(`onboarding-bulk-reset: targeting ${allUsers.length} user(s) at ${baseUrl}`);
|
||||
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
|
||||
|
||||
const results: ResetOutcome[] = [];
|
||||
for (const u of allUsers) {
|
||||
try {
|
||||
const outcome = await resetOne(baseUrl, serviceToken, u.id);
|
||||
results.push(outcome);
|
||||
if (outcome.ok) {
|
||||
console.log(` ok ${u.id} (${u.email}) — ${outcome.ledgerRowsDeleted} ledger row(s) deleted`);
|
||||
} else {
|
||||
console.log(` ERROR ${u.id} (${u.email}) — status ${outcome.status}: ${outcome.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({ userId: u.id, ok: false, error: message, status: 0 });
|
||||
console.log(` ERROR ${u.id} (${u.email}) — threw: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
const errorCount = results.length - okCount;
|
||||
const ledgerTotal = results.reduce((sum, r) => (r.ok ? sum + r.ledgerRowsDeleted : sum), 0);
|
||||
|
||||
console.log("");
|
||||
console.log(`onboarding-bulk-reset: complete — ${okCount} ok, ${errorCount} error(s), ${ledgerTotal} ledger row(s) deleted`);
|
||||
|
||||
log.info({ okCount, errorCount, ledgerTotal, total: results.length }, "onboarding bulk reset complete");
|
||||
process.exit(errorCount > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("onboarding-bulk-reset: fatal", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -70,5 +70,45 @@ assert.equal(
|
||||
"invalid completion timestamps should normalize to a valid ISO timestamp",
|
||||
);
|
||||
|
||||
// ── Regression: underscore completion aliases must be valid (reset deletes them) ──
|
||||
// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent
|
||||
// accepts is also in the reset delete scope. Pin both sides agree per alias.
|
||||
for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) {
|
||||
const dotted = normalizeOnboardingEventType(underscore);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: underscore, payload: {} }),
|
||||
true,
|
||||
`${underscore} should satisfy onboarding status (normalized to ${dotted})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ──
|
||||
// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt
|
||||
// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status
|
||||
// validity) agrees a snapshot carrying any of these is a completion snapshot.
|
||||
for (const [label, payload] of [
|
||||
["top-level completed_at", { completed_at: now }],
|
||||
["top-level completedAt", { completedAt: now }],
|
||||
["onboarding.completed_at", { onboarding: { completed_at: now } }],
|
||||
["onboarding.completedAt", { onboarding: { completedAt: now } }],
|
||||
] as const) {
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload(payload),
|
||||
now,
|
||||
`completion timestamp extracted from ${label}`,
|
||||
);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }),
|
||||
true,
|
||||
`snapshot with ${label} is a completion snapshot`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: intermediate snapshots (no completion marker) stay invalid ──
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }),
|
||||
undefined,
|
||||
"intermediate snapshot has no completion timestamp",
|
||||
);
|
||||
console.log("onboarding-ledger tests passed");
|
||||
process.exit(0);
|
||||
|
||||
160
scripts/onboarding-preferences.test.ts
Normal file
160
scripts/onboarding-preferences.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DEFAULT_ONBOARDING_DATA,
|
||||
defaultOnboardingData,
|
||||
extractOnboardingData,
|
||||
assertOnboardingRevision,
|
||||
deriveOnboardingPayload,
|
||||
mergeOnboardingPatch,
|
||||
OnboardingRevisionConflict,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
/**
|
||||
* Focused tests for the onboarding preference helpers (pure — no DB/network).
|
||||
* Covers: default v3 shape, revision conflict, partial update merge,
|
||||
* status/completed_at consistency + payload derivation, and unrelated
|
||||
* preference preservation. Matches the scripts/X.test.ts convention
|
||||
* (node:assert over pure functions).
|
||||
*/
|
||||
|
||||
// ── 1. default v3 response ───────────────────────────────────────────────────
|
||||
{
|
||||
const def = defaultOnboardingData();
|
||||
assert.equal(def.schema_version, 3, "default is v3");
|
||||
assert.equal(def.revision, 0);
|
||||
assert.equal(def.status, "in_progress");
|
||||
assert.equal(def.access_choice, null);
|
||||
assert.equal(def.qx_estimate, null);
|
||||
assert.equal(def.completed_at, null);
|
||||
assert.equal(def.consent.privacy_accepted, false);
|
||||
assert.equal(def.progress.stage, "consent");
|
||||
// DEFAULT constant must match the factory.
|
||||
assert.deepEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
// Factory returns a fresh clone, not the shared constant.
|
||||
assert.notEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
}
|
||||
|
||||
// extractOnboardingData returns the default when nothing is stored.
|
||||
{
|
||||
const empty = extractOnboardingData(undefined);
|
||||
assert.equal(empty.schema_version, 3);
|
||||
assert.equal(empty.revision, 0);
|
||||
const fromNonObject = extractOnboardingData("nonsense");
|
||||
assert.equal(fromNonObject.status, "in_progress");
|
||||
}
|
||||
|
||||
// ── 2. revision conflict (optimistic concurrency) ───────────────────────────
|
||||
{
|
||||
const current = extractOnboardingData({ revision: 5 });
|
||||
// Matching revision is accepted (no throw).
|
||||
assert.doesNotThrow(() => assertOnboardingRevision(5, current));
|
||||
|
||||
// Mismatched revision throws the typed conflict.
|
||||
assert.throws(
|
||||
() => assertOnboardingRevision(4, current),
|
||||
(err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. partial update merge (revision bump + progress stamp) ────────────────
|
||||
{
|
||||
const stored = extractOnboardingData({ revision: 2 });
|
||||
const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } };
|
||||
const merged = mergeOnboardingPatch({ onboarding: stored }, incoming);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
|
||||
assert.equal(next.revision, 3, "revision bumps by one from the stored value");
|
||||
assert.equal(next.profile.mode, "founder", "incoming profile change is applied");
|
||||
assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save");
|
||||
}
|
||||
|
||||
// ── 4. status/completed_at consistency + payload derivation ─────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const completed = {
|
||||
...base,
|
||||
revision: 0,
|
||||
status: "completed" as const,
|
||||
profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
|
||||
responses: {
|
||||
...base.responses,
|
||||
career_barriers: ["no-network"],
|
||||
desired_outcomes: ["interviews", "offer"],
|
||||
target_role: "Product Intern",
|
||||
target_field: "technical",
|
||||
weekly_time_commitment: "5-10",
|
||||
experience_level: "0-2",
|
||||
work_context: "audience",
|
||||
},
|
||||
// deliberately omit completed_at; merge must stamp it.
|
||||
};
|
||||
|
||||
const merged = mergeOnboardingPatch({}, completed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "completed");
|
||||
assert.ok(next.completed_at, "completed status without completed_at is stamped");
|
||||
|
||||
// Payload is a faithful projection — no invented completion math.
|
||||
const payload = deriveOnboardingPayload(next);
|
||||
assert.equal(payload.schema_version, 1);
|
||||
assert.equal(payload.source_revision, next.revision);
|
||||
assert.equal(payload.mode, "student");
|
||||
assert.equal(payload.onboarding_icp, "intern");
|
||||
assert.equal(payload.target_role, "Product Intern");
|
||||
assert.equal(payload.target_field, "technical");
|
||||
assert.deepEqual(payload.goals, ["interviews", "offer"]);
|
||||
assert.deepEqual(payload.barriers, ["no-network"]);
|
||||
assert.equal(payload.weekly_time_commitment, "5-10");
|
||||
assert.equal(payload.experience_context, "0-2 · audience");
|
||||
}
|
||||
|
||||
// completed_at is cleared when regressing to in_progress with an explicit null.
|
||||
{
|
||||
const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" };
|
||||
const regressed = { ...completed, status: "in_progress" as const, completed_at: null };
|
||||
const merged = mergeOnboardingPatch({}, regressed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "in_progress");
|
||||
assert.equal(next.completed_at, null, "stale completed_at is cleared on regression");
|
||||
}
|
||||
|
||||
// ── 5. unrelated preference preservation ────────────────────────────────────
|
||||
{
|
||||
const preferences = {
|
||||
onboarding: { revision: 1, status: "in_progress" },
|
||||
interview_preferences: { focus_areas: ["behavioral"] },
|
||||
resume_preferences: { target_title: "Data Scientist" },
|
||||
mission_preferences: { active_goal: "land-offer" },
|
||||
target_roles: ["Product Intern"],
|
||||
target_companies: ["Acme"],
|
||||
};
|
||||
const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } };
|
||||
const merged = mergeOnboardingPatch(preferences, incoming);
|
||||
|
||||
// The onboarding blob is updated.
|
||||
assert.equal(extractOnboardingData(merged.onboarding).revision, 2);
|
||||
// Every unrelated preference key is preserved verbatim.
|
||||
assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] });
|
||||
assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" });
|
||||
assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" });
|
||||
assert.deepEqual(merged.target_roles, ["Product Intern"]);
|
||||
assert.deepEqual(merged.target_companies, ["Acme"]);
|
||||
}
|
||||
|
||||
// ── 6. access_choice persistence (trial | full | null) ───────────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const trial = { ...base, revision: 0, access_choice: "trial" as const };
|
||||
const mergedTrial = mergeOnboardingPatch({}, trial);
|
||||
assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial");
|
||||
|
||||
const full = { ...base, revision: 0, access_choice: "full" as const };
|
||||
const mergedFull = mergeOnboardingPatch({}, full);
|
||||
assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full");
|
||||
|
||||
// Garbage access_choice is rejected back to null by extraction.
|
||||
const garbage = extractOnboardingData({ access_choice: "pro" });
|
||||
assert.equal(garbage.access_choice, null);
|
||||
}
|
||||
|
||||
console.log("onboarding-preferences: all assertions passed");
|
||||
138
scripts/onboarding-reset.test.ts
Normal file
138
scripts/onboarding-reset.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
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");
|
||||
}
|
||||
console.log("onboarding-reset tests passed");
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user