/** * AI Software Factory — seed data verification (TASK-0011). * * `verifySeedData(prisma)` asserts the contracted demo fixture that later * integration/E2E tests rely on. Any failed check produces a table of * expected vs actual and a non-zero exit. * * Usage: * - `pnpm db:seed` calls this at the end of seeding (TASK-0011 wires it). * - `pnpm db:verify` (this file run standalone via tsx) runs the same * assertions read-only against the current database. * * The demo surface is CONTRACTED: `demo@factory.local`, the `demo` workspace * slug, the "Getting started" page, the "Tasks" database. Personal workspace * slugs are `personal-<4 random chars>` and are never hardcoded anywhere — * E2E resolves them via the API. */ import { PrismaClient } from "@prisma/client"; import { comparePositions, isValidPosition } from "../packages/shared/src/positions.js"; const DEMO_EMAIL = "demo@factory.local"; const ADMIN_EMAIL = "admin@factory.local"; export interface CheckResult { name: string; expected: string; actual: string; ok: boolean; } interface ModelCounts { users: number; workspaces: number; teams: number; pages: number; databases: number; records: number; comments: number; favorites: number; } /** * Run every contracted assertion against `prisma` (read-only). Returns the * full check list; throws (with the expected-vs-actual table rendered) when * any check fails so both the seed and the standalone runner exit non-zero. */ export async function verifySeedData(prisma: PrismaClient): Promise { const checks: CheckResult[] = []; const add = (name: string, expected: string, actual: string, ok: boolean): void => { checks.push({ name, expected, actual, ok }); }; const count = (name: string, expected: number, actual: number): void => { add(name, String(expected), String(actual), expected === actual); }; /* ── per-model report counts ──────────────────────────────────────────── */ const counts: ModelCounts = { users: await prisma.user.count(), workspaces: await prisma.workspace.count(), teams: await prisma.team.count(), pages: await prisma.page.count(), databases: await prisma.database.count(), records: await prisma.databaseRecord.count(), comments: await prisma.comment.count(), favorites: await prisma.pageFavorite.count() }; /* ── 1. Both demo users VERIFIED/ACTIVE with a passwordHash ───────────── */ for (const email of [DEMO_EMAIL, ADMIN_EMAIL]) { const user = await prisma.user.findUnique({ where: { email } }); add( `user ${email} exists VERIFIED/ACTIVE with passwordHash`, "user, emailVerification=VERIFIED, status=ACTIVE, passwordHash non-null", user ? `emailVerification=${user.emailVerification}, status=${user.status}, passwordHash=${ user.passwordHash ? "set" : "null" }` : "missing", Boolean(user && user.emailVerification === "VERIFIED" && user.status === "ACTIVE" && user.passwordHash) ); } const demoUser = await prisma.user.findUnique({ where: { email: DEMO_EMAIL } }); const adminUser = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } }); /* ── 2. slug `demo`: non-personal, owned by the demo user ─────────────── */ const demoWs = await prisma.workspace.findUnique({ where: { slug: "demo" } }); add( "workspace slug `demo` non-personal, owned by demo user", "workspace, isPersonal=false, ownerId=demo user", demoWs ? `isPersonal=${demoWs.isPersonal}, ownerId=${demoWs.ownerId ?? "null"}` : "missing", Boolean(demoWs && !demoWs.isPersonal && demoUser && demoWs.ownerId === demoUser.id) ); /* ── 3. Exactly 2 personal workspaces (one per demo user) ─────────────── */ const personalWs = await prisma.workspace.findMany({ where: { isPersonal: true }, select: { slug: true, ownerId: true } }); add( "exactly 2 personal workspaces (one per demo user)", "2, each matching the `personal-<4 random chars>` shape", personalWs.map((w) => `${w.slug}→${w.ownerId}`).join(", "), personalWs.length === 2 && personalWs.every((w) => /^personal-[a-z0-9]{4}$/.test(w.slug)) && new Set(personalWs.map((w) => w.ownerId)).size === 2 ); /* ── 4. Every set ownerId has an OWNER WorkspaceMember row ────────────── */ const owned = await prisma.workspace.findMany({ where: { ownerId: { not: null } }, select: { id: true, slug: true, ownerId: true, members: { select: { userId: true, role: true } } } }); const ownerViolations = owned.filter( (w) => !w.members.some((m) => m.userId === w.ownerId && m.role === "OWNER") ); add( "every workspace ownerId has an OWNER WorkspaceMember row", "0 violations", ownerViolations.map((w) => w.slug).join(", "), ownerViolations.length === 0 ); if (!demoWs) { failTable(checks, counts); return checks; } const wsId = demoWs.id; /* ── 5. Exactly 2 teams in the demo ws ────────────────────────────────── */ const teams = await prisma.team.findMany({ where: { workspaceId: wsId } }); const teamSlugs = teams.map((t) => t.slug); add( "demo ws: exactly 2 teams (unique slugs, valid fractional positions)", "2 teams, unique slugs, valid positions", teams .map((t) => `${t.slug}:${t.position}${isValidPosition(t.position) ? "" : "(INVALID)"}`) .join(", "), teams.length === 2 && new Set(teamSlugs).size === 2 && teams.every((t) => isValidPosition(t.position)) ); /* ── 6. Exactly 6 pages in the demo ws ────────────────────────────────── */ // The record=page invariant (DatabaseRecord.pageId @unique) materializes each // record as a page, so the demo ws page count includes the 3 Tasks record // pages. "Exactly 6 pages" = 6 total = 3 content pages ("Getting started" // + its 2 children) + 3 record pages. const allPages = await prisma.page.findMany({ where: { workspaceId: wsId } }); count("demo ws: exactly 6 pages (3 content + 3 record)", 6, allPages.length); const recordPages = new Set( (await prisma.databaseRecord.findMany({ where: { workspaceId: wsId }, select: { pageId: true } })).map((r) => r.pageId) .filter((p): p is string => p !== null) ); const contentPages = allPages.filter((p) => !recordPages.has(p.id)); count("demo ws: 3 record pages (1:1 with the 3 Tasks records)", 3, recordPages.size); // Every parentId (across ALL 6 pages) points at a same-workspace page. const pageIds = new Set(allPages.map((p) => p.id)); const parentViolations = allPages.filter((p) => p.parentId !== null && !pageIds.has(p.parentId)); add( "every page parentId is a same-workspace page", "0 violations", parentViolations.map((p) => `${p.title}→${p.parentId}`).join(", "), parentViolations.length === 0 ); // Positions unique per parent (across ALL 6 pages) — shared comparator. const byParent = new Map(); for (const p of allPages) { const key = p.parentId ?? "__root__"; const list = byParent.get(key) ?? []; list.push(p); byParent.set(key, list); } let positionDupes = 0; let positionInvalid = 0; for (const list of byParent.values()) { for (const p of list) { if (!isValidPosition(p.position)) positionInvalid += 1; } const sorted = [...list].sort((a, b) => comparePositions(a.position, b.position)); for (let i = 1; i < sorted.length; i += 1) { if (comparePositions(sorted[i - 1]!.position, sorted[i]!.position) === 0) positionDupes += 1; } } add( "positions unique per parent (shared comparator) and valid", "0 duplicates, 0 invalid", `${positionDupes} duplicates, ${positionInvalid} invalid`, positionDupes === 0 && positionInvalid === 0 ); const gettingStarted = contentPages.find((p) => p.title === "Getting started"); const gsChildren = gettingStarted ? contentPages.filter((p) => p.parentId === gettingStarted.id) : []; add( `"Getting started" has exactly 2 direct children`, "2", gsChildren.map((p) => p.title).join(", "), gettingStarted !== undefined && gsChildren.length === 2 ); /* ── 7. One "Tasks" database: 5 properties, 3 records, 1 TABLE view ───── */ const dbs = await prisma.database.findMany({ where: { workspaceId: wsId } }); const tasksDb = dbs.find((d) => d.title === "Tasks"); add( "demo ws: exactly 1 database titled `Tasks`", "1", dbs.map((d) => d.title).join(", "), tasksDb !== undefined && dbs.length === 1 ); if (tasksDb) { const props = await prisma.databaseProperty.findMany({ where: { databaseId: tasksDb.id } }); const shape = { title: props.filter((p) => p.type === "TITLE").length, select: props.filter((p) => p.type === "SELECT").length, date: props.filter((p) => p.type === "DATE").length, person: props.filter((p) => p.type === "PERSON").length }; add( "Tasks db: exactly 5 properties (title, 2× select, date, person) — order-insensitive", "title=1 select=2 date=1 person=1 (total 5)", `title=${shape.title} select=${shape.select} date=${shape.date} person=${shape.person} (total ${props.length})`, shape.title === 1 && shape.select === 2 && shape.date === 1 && shape.person === 1 && props.length === 5 ); const records = await prisma.databaseRecord.findMany({ where: { databaseId: tasksDb.id }, include: { page: true } }); const nonEmptyTitles = records.filter((r) => r.page && r.page.title.trim().length > 0); add( "Tasks db: exactly 3 records with non-empty titles (each 1:1 with a page)", "3 records, each with a non-empty page title", `${records.length} records, ${nonEmptyTitles.length} with non-empty page titles`, records.length === 3 && nonEmptyTitles.length === 3 ); const tableViews = await prisma.databaseView.findMany({ where: { databaseId: tasksDb.id, type: "TABLE" } }); count("Tasks db: exactly 1 TABLE view", 1, tableViews.length); } /* ── 8. Exactly 1 comment on "Getting started" by the admin user ──────── */ if (gettingStarted && adminUser) { const comments = await prisma.comment.findMany({ where: { pageId: gettingStarted.id } }); add( `"Getting started": exactly 1 comment, authored by the admin user`, "1 comment by admin", `${comments.length} comments (${comments .map((c) => c.createdBy) .join(", ")})`, comments.length === 1 && comments[0]?.createdBy === adminUser.id ); } else { add(`"Getting started": exactly 1 comment, authored by the admin user`, "1 comment by admin", "page or admin user missing", false); } /* ── 9. Demo user favorites "Getting started" ──────────────────────────── */ if (gettingStarted && demoUser) { const favs = await prisma.pageFavorite.findMany({ where: { workspaceId: wsId, userId: demoUser.id, pageId: gettingStarted.id } }); count("demo user favorites `Getting started` (1 PageFavorite row)", 1, favs.length); } else { add("demo user favorites `Getting started` (1 PageFavorite row)", "1", "page or demo user missing", false); } failTable(checks, counts); return checks; } /** * Render the report. Throws on the first failing check so the process exits * non-zero (both the seed and the standalone runner rely on this). */ function failTable(checks: CheckResult[], counts: ModelCounts): void { const failed = checks.filter((c) => !c.ok); console.log("\n📋 Seed verification report"); console.log( " counts: " + ["users", "workspaces", "teams", "pages", "databases", "records", "comments", "favorites"] .map((k) => `${k}=${counts[k as keyof ModelCounts]}`) .join(" ") ); for (const c of checks) { console.log(` [${c.ok ? "ok" : "FAIL"}] ${c.name}`); if (!c.ok) { console.log(` expected: ${c.expected}`); console.log(` actual: ${c.actual}`); } } if (failed.length > 0) { console.error(`\n❌ Seed verification FAILED: ${failed.length}/${checks.length} checks`); const table: string[] = ["| check | expected | actual |", "| --- | --- | --- |"]; for (const c of failed) { table.push(`| ${c.name} | ${c.expected} | ${c.actual} |`); } console.error(table.join("\n")); throw new Error(`seed verification failed: ${failed.length} check(s) (see report above)`); } console.log(`\n✅ Seed verification passed: ${checks.length}/${checks.length} checks`); } /* ── standalone runner: pnpm db:verify ───────────────────────────────────── */ const isMain = typeof process.argv[1] === "string" && process.argv[1].length > 0 && (process.argv[1].endsWith("verify.ts") || process.argv[1].endsWith("verify.js")); if (isMain) { const prisma = new PrismaClient(); verifySeedData(prisma) .catch((e) => { console.error(e instanceof Error ? e.message : e); process.exitCode = 1; }) .finally(() => prisma.$disconnect()); }