149 lines
6.4 KiB
TypeScript
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]!,
|
|
}));
|
|
}
|