Files
growqr-backend/scripts/curator-persisted-reconcile.test.ts
2026-07-13 03:43:37 +05:30

149 lines
6.4 KiB
TypeScript

import assert from "node:assert/strict";
import { eq, sql } from "drizzle-orm";
import { db } from "../src/db/client.js";
import { growEvents, onboarding, users } from "../src/db/schema.js";
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
let databaseAvailable = Boolean(process.env.DATABASE_URL);
if (databaseAvailable) {
try {
await db.execute(sql`select 1`);
} catch {
databaseAvailable = false;
}
}
if (!databaseAvailable) {
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
} else {
const userId = `curator-reconcile-regression-${Date.now()}`;
const startDate = new Date().toISOString().slice(0, 10);
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
const staleReadyPayload = {
sprintId,
startDate,
durationDays: 2,
access: "trial",
variantId: "student_recent_grad",
planFingerprint: staleFingerprint,
};
try {
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
await db.insert(onboarding).values({
userId,
data: {
status: "completed",
access_choice: "trial",
profile: { icp: "experienced" },
},
payload: {
onboarding_icp: "experienced",
curator_registry_icp: "experienced_professional",
},
});
await db.insert(growEvents).values([
{
userId,
source: "curator-v1",
type: "curator.sprint.started",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.sprint.started:stale`,
payload: {
sprintId,
startDate,
durationDays: 2,
access: "trial",
version: "icp-v10-static",
variantId: "student_recent_grad",
},
},
{
userId,
source: "curator-v1",
type: "curator.onboarding_plan.ready",
category: "mission",
occurredAt: new Date(),
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
payload: staleReadyPayload,
},
]);
const context = {
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
};
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(first.status, "ready", "stale ready event must be replaced");
const day1 = await buildCuratorSprint(userId, startDate);
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
const noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1));
const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? [];
assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior");
assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks");
await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({
userId,
source: "curator-v1",
type: "curator.task.completed",
category: "mission" as const,
occurredAt: new Date(Date.now() + index),
dedupeKey: `${userId}:curator.task.completed:${task.id}`,
payload: { taskId: task.id },
})));
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks");
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
.from(growEvents).where(eq(growEvents.userId, userId));
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
const starts = rows.filter((row) => row.type === "curator.sprint.started");
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
assert.ok(replacementStart, "experienced replacement started event must be persisted");
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
console.log("curator persisted reconciliation integration passed");
} finally {
await db.delete(users).where(eq(users.id, userId));
await db.execute(sql`select 1`);
}
}
function addDays(date: string, days: number) {
const value = new Date(`${date}T00:00:00.000Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
const plan = curatorSprintTaskPlan({
startDate,
variantId: "experienced_professional",
durationDays: 2,
access: "trial",
});
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
id,
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
}));
}