From ad6024f3d97bff540302479cf6509ef0e7b1a0ae Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 18:59:23 +0000 Subject: [PATCH] feat: deterministic seed, impersonation migration, test factories (GRO-110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Seed Hardening: - Replace all Math.random() calls in seed.ts with a Mulberry32 seeded PRNG (seed 42) so the same data set is reproduced on every run - Replace crypto.randomUUID() with a PRNG-based UUID v4 generator - Add manager (Jordan Lee) and receptionist (Sam Rivera) staff members to seed — previously all staff were groomers - New packages/db/src/reset.ts drops all tables/enums and re-runs migrate + seed; exposed as `pnpm db:reset` at root - Generate migration 0010_impersonation_sessions.sql for the impersonation_sessions and impersonation_audit_logs tables that were already in schema.ts but had no corresponding migration Phase 2 — Test Factories: - New packages/db/src/factories.ts with buildStaff, buildClient, buildPet, buildService, buildAppointment and resetFactoryCounters helpers - Exported via @groombook/db/factories subpath (package.json + vitest alias) - impersonation.test.ts updated to use buildStaff instead of hand-rolled fixture objects Closes #90 (Phases 1 + 2) Co-Authored-By: Paperclip --- apps/api/src/__tests__/impersonation.test.ts | 18 +-- apps/api/vitest.config.ts | 1 + package.json | 3 +- .../0010_impersonation_sessions.sql | 26 ++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/package.json | 5 + packages/db/src/factories.ts | 141 ++++++++++++++++++ packages/db/src/reset.ts | 70 +++++++++ packages/db/src/seed.ts | 98 ++++++++---- 9 files changed, 327 insertions(+), 42 deletions(-) create mode 100644 packages/db/migrations/0010_impersonation_sessions.sql create mode 100644 packages/db/src/factories.ts create mode 100644 packages/db/src/reset.ts diff --git a/apps/api/src/__tests__/impersonation.test.ts b/apps/api/src/__tests__/impersonation.test.ts index a76a655..2ba232f 100644 --- a/apps/api/src/__tests__/impersonation.test.ts +++ b/apps/api/src/__tests__/impersonation.test.ts @@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { JwtPayload } from "../middleware/auth.js"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { buildStaff } from "@groombook/db/factories"; -// ─── Mock data ─────────────────────────────────────────────────────────────── +// ─── Mock data (built with factories for schema-safe defaults) ──────────────── -const MANAGER_STAFF = { - id: "staff-manager-id", - oidcSub: "oidc-manager-sub", - role: "manager", - name: "Manager", -}; - -const GROOMER_STAFF = { - id: "staff-groomer-id", - oidcSub: "oidc-groomer-sub", - role: "groomer", - name: "Groomer", -}; +const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" }); +const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" }); const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" }; diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 0303dd8..f8e2c3a 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -4,6 +4,7 @@ import path from "path"; export default defineConfig({ resolve: { alias: { + "@groombook/db/factories": path.resolve(__dirname, "../../packages/db/src/factories.ts"), "@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"), }, }, diff --git a/package.json b/package.json index 463a4ae..ebf426b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "typecheck": "pnpm -r typecheck", "test": "pnpm -r --filter='!@groombook/e2e' test", "db:migrate": "pnpm --filter @groombook/db migrate", - "db:seed": "pnpm --filter @groombook/db seed" + "db:seed": "pnpm --filter @groombook/db seed", + "db:reset": "pnpm --filter @groombook/db reset" }, "engines": { "node": ">=20", diff --git a/packages/db/migrations/0010_impersonation_sessions.sql b/packages/db/migrations/0010_impersonation_sessions.sql new file mode 100644 index 0000000..77faf98 --- /dev/null +++ b/packages/db/migrations/0010_impersonation_sessions.sql @@ -0,0 +1,26 @@ +-- Create impersonation_session_status enum and tables +CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired'); + +CREATE TABLE "impersonation_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "staff_id" uuid NOT NULL, + "client_id" uuid NOT NULL, + "reason" text, + "status" "impersonation_session_status" DEFAULT 'active' NOT NULL, + "started_at" timestamp DEFAULT now() NOT NULL, + "ended_at" timestamp, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict, + CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict +); + +CREATE TABLE "impersonation_audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "action" text NOT NULL, + "page_visited" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade +); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 2b1b6b9..d0fbd7f 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773993600000, "tag": "0009_client_soft_delete", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1742500800000, + "tag": "0010_impersonation_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index dadfe80..2fbdd7d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,6 +9,10 @@ ".": { "default": "./dist/index.js", "types": "./src/index.ts" + }, + "./factories": { + "default": "./src/factories.ts", + "types": "./src/factories.ts" } }, "scripts": { @@ -16,6 +20,7 @@ "generate": "drizzle-kit generate", "migrate": "drizzle-kit migrate", "seed": "tsx src/seed.ts", + "reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts new file mode 100644 index 0000000..63f4a26 --- /dev/null +++ b/packages/db/src/factories.ts @@ -0,0 +1,141 @@ +/** + * Test factories — build typed in-memory entities for unit tests. + * + * Each factory returns a fully-populated object with valid defaults. + * Pass an overrides object to customise specific fields. + * + * IDs are generated with a deterministic counter so tests produce stable, + * readable values (e.g. "staff-1", "client-2") without needing crypto. + * + * Usage: + * import { buildStaff, buildClient, buildPet } from "@groombook/db/factories"; + * + * const manager = buildStaff({ role: "manager" }); + * const client = buildClient({ name: "Alice Smith" }); + * const pet = buildPet({ clientId: client.id }); + */ + +import type { staff, clients, pets, services, appointments } from "./schema.js"; + +// ── Counter-based ID factory ───────────────────────────────────────────────── + +const counters: Record = {}; + +function nextId(prefix: string): string { + counters[prefix] = (counters[prefix] ?? 0) + 1; + return `${prefix}-${counters[prefix]}`; +} + +/** Reset all counters. Call in beforeEach() to keep tests independent. */ +export function resetFactoryCounters(): void { + for (const key of Object.keys(counters)) { + delete counters[key]; + } +} + +// ── Type aliases ───────────────────────────────────────────────────────────── + +export type StaffRow = typeof staff.$inferSelect; +export type ClientRow = typeof clients.$inferSelect; +export type PetRow = typeof pets.$inferSelect; +export type ServiceRow = typeof services.$inferSelect; +export type AppointmentRow = typeof appointments.$inferSelect; + +// ── Factories ──────────────────────────────────────────────────────────────── + +export function buildStaff(overrides: Partial = {}): StaffRow { + const id = nextId("staff"); + return { + id, + name: `Staff Member ${id}`, + email: `${id}@groombook.test`, + oidcSub: `oidc-${id}`, + role: "groomer", + active: true, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildClient(overrides: Partial = {}): ClientRow { + const id = nextId("client"); + return { + id, + name: `Client ${id}`, + email: `${id}@example.com`, + phone: "555-0100", + address: "1 Main St, Springfield, CA 90000", + notes: null, + emailOptOut: false, + status: "active", + disabledAt: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildPet(overrides: Partial & { clientId: string }): PetRow { + const id = nextId("pet"); + const defaults: PetRow = { + id, + clientId: overrides.clientId, + name: `Pet ${id}`, + species: "Dog", + breed: "Mixed Breed", + weightKg: "15.00", + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + customFields: {}, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + }; + return { ...defaults, ...overrides }; +} + +export function buildService(overrides: Partial = {}): ServiceRow { + const id = nextId("service"); + return { + id, + name: `Service ${id}`, + description: "A grooming service", + basePriceCents: 6500, + durationMinutes: 60, + active: true, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }; +} + +export function buildAppointment( + overrides: Partial & { clientId: string; petId: string; serviceId: string; staffId: string } +): AppointmentRow { + const id = nextId("appointment"); + const startTime = new Date("2025-06-01T10:00:00Z"); + const endTime = new Date("2025-06-01T11:00:00Z"); + const defaults: AppointmentRow = { + id, + clientId: overrides.clientId, + petId: overrides.petId, + serviceId: overrides.serviceId, + staffId: overrides.staffId, + batherStaffId: null, + seriesId: null, + seriesIndex: null, + groupId: null, + status: "scheduled", + startTime, + endTime, + notes: null, + priceCents: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T00:00:00Z"), + }; + return { ...defaults, ...overrides }; +} diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts new file mode 100644 index 0000000..c390f1c --- /dev/null +++ b/packages/db/src/reset.ts @@ -0,0 +1,70 @@ +/** + * reset.ts — Drop all application tables and re-run migrations + seed. + * + * Intended for local development only. Never run against production. + * + * Usage: + * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + */ + +import postgres from "postgres"; + +async function reset() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + if (process.env.NODE_ENV === "production") { + console.error("[FATAL] db:reset must not be run in production."); + process.exit(1); + } + + const client = postgres(url, { max: 1 }); + + console.log("Dropping all application tables...\n"); + + // Drop in dependency order (children before parents) + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `; + + // Drop custom enums + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT typname FROM pg_type + WHERE typtype = 'e' AND typnamespace = ( + SELECT oid FROM pg_namespace WHERE nspname = 'public' + ) + ) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; + + // Drop the drizzle migrations tracking table + await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; + await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; + + console.log("✓ All tables and enums dropped\n"); + + await client.end(); +} + +reset().catch((err) => { + console.error("Reset failed:", err); + process.exit(1); +}); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index f0538ae..55ea5c6 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -1,14 +1,17 @@ /** - * Seed script — generates realistic test data for Groom Book development. + * Seed script — generates deterministic, PII-free test data for Groom Book. * * Creates: - * - 3 groomers + 3 bathers (staff) + * - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total) * - 10 services * - 500 clients, each with 1-3 dogs * - ~2 500 appointments spread across the past 12 months * - Invoices for completed appointments with line items and tip splits * - Grooming visit logs for completed appointments * + * Output is fully deterministic: the same seed value always produces the + * same rows with the same IDs. + * * Usage: * DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts */ @@ -17,29 +20,61 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import * as schema from "./schema.js"; -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── -function pick(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]!; +/** + * Returns a seeded pseudo-random number generator. + * Same seed → identical sequence of numbers every run. + */ +function createPrng(seed: number): () => number { + let s = seed | 0; + return function (): number { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; } +const rand = createPrng(42); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Return a random element from an array using the seeded PRNG. */ +function pick(arr: T[]): T { + return arr[Math.floor(rand() * arr.length)]!; +} + +/** Return n distinct random elements from an array. */ function pickN(arr: T[], n: number): T[] { - const shuffled = [...arr].sort(() => Math.random() - 0.5); + const shuffled = [...arr].sort(() => rand() - 0.5); return shuffled.slice(0, n); } function randInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; + return Math.floor(rand() * (max - min + 1)) + min; } function randDate(start: Date, end: Date): Date { - return new Date( - start.getTime() + Math.random() * (end.getTime() - start.getTime()) - ); + return new Date(start.getTime() + rand() * (end.getTime() - start.getTime())); } +/** + * Generate a deterministic UUID v4 from the seeded PRNG. + * Conforms to RFC 4122 §4.4 (variant bits set correctly). + */ function uuid(): string { - return crypto.randomUUID(); + const hex = (n: number) => n.toString(16).padStart(2, "0"); + const bytes = Array.from({ length: 16 }, () => Math.floor(rand() * 256)); + bytes[6] = ((bytes[6]! & 0x0f) | 0x40); // version 4 + bytes[8] = ((bytes[8]! & 0x3f) | 0x80); // variant bits + return [ + bytes.slice(0, 4).map(hex).join(""), + bytes.slice(4, 6).map(hex).join(""), + bytes.slice(6, 8).map(hex).join(""), + bytes.slice(8, 10).map(hex).join(""), + bytes.slice(10, 16).map(hex).join(""), + ].join("-"); } // ── Data pools ─────────────────────────────────────────────────────────────── @@ -227,6 +262,15 @@ async function seed() { console.log("Seeding Groom Book database...\n"); // ── Staff ── + // Deterministic staff IDs so they can be referenced in scripts/tests + const managerStaff = [ + { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const }, + ]; + + const receptionistStaff = [ + { id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const }, + ]; + const groomers = [ { id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const }, { id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const }, @@ -240,7 +284,7 @@ async function seed() { { id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const }, ]; - const allStaff = [...groomers, ...bathers]; + const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; for (const s of allStaff) { await db.insert(schema.staff).values({ id: s.id, @@ -250,7 +294,7 @@ async function seed() { active: true, }); } - console.log(`✓ Created ${allStaff.length} staff (3 groomers, 3 bathers)`); + console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`); // ── Services ── const serviceIds: string[] = []; @@ -274,7 +318,7 @@ async function seed() { oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); interface ClientRecord { id: string; name: string } - interface PetRecord { id: string; clientId: string; name: string } + interface PetRecord { id: string; clientId: string } const clientRecords: ClientRecord[] = []; const petRecords: PetRecord[] = []; @@ -301,14 +345,14 @@ async function seed() { email, phone, address: addr, - notes: Math.random() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null, - emailOptOut: Math.random() < 0.1, + notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null, + emailOptOut: rand() < 0.1, }); clientRecords.push({ id: clientId, name }); // 1-3 pets per client - const petCount = Math.random() < 0.5 ? 1 : Math.random() < 0.7 ? 2 : 3; + const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3; for (let p = 0; p < petCount; p++) { const petId = uuid(); const breed = pick(dogBreeds); @@ -322,17 +366,17 @@ async function seed() { name: pick(dogNames), species: "Dog", breed, - weightKg: String(randInt(3, 60) + Math.random().toFixed(1).slice(1)), + weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)), dateOfBirth: dob, healthAlerts: pick(healthAlerts), groomingNotes: pick(groomingNotes), cutStyle: pick(cutStyles), shampooPreference: pick(shampoos), - specialCareNotes: Math.random() < 0.1 ? "Vet clearance required before grooming" : null, + specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, customFields: {}, }); - petRecords.push({ id: petId, clientId, name: "" }); + petRecords.push({ id: petId, clientId }); } } @@ -387,13 +431,13 @@ async function seed() { // Group pets by client for efficient appointment generation const petsByClient = new Map(); for (const pet of petRecords) { - const arr = petsByClient.get(pet.clientId) || []; + const arr = petsByClient.get(pet.clientId) ?? []; arr.push(pet.id); petsByClient.set(pet.clientId, arr); } for (const client of clientRecords) { - const pets = petsByClient.get(client.id) || []; + const pets = petsByClient.get(client.id) ?? []; // Each client visits ~3-8 times over the year const visitCount = randInt(3, 8); @@ -404,7 +448,7 @@ async function seed() { const serviceId = serviceIds[serviceIdx]!; const svc = servicesDef[serviceIdx]!; const groomer = pick(groomers); - const bather = Math.random() < 0.6 ? pick(bathers) : null; + const bather = rand() < 0.6 ? pick(bathers) : null; const status = pick(statuses); // Schedule within the past year, or next 2 weeks for upcoming @@ -419,7 +463,7 @@ async function seed() { const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); const apptId = uuid(); - const priceCents = Math.random() < 0.2 ? svc.price + randInt(-500, 1000) : null; + const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null; const effectivePrice = priceCents ?? svc.price; apptBatch.push({ @@ -440,11 +484,11 @@ async function seed() { // Create invoice for completed appointments if (status === "completed") { const invoiceId = uuid(); - const tipCents = Math.random() < 0.7 ? randInt(200, 3000) : 0; + const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; const taxCents = Math.round(effectivePrice * 0.08); const totalCents = effectivePrice + taxCents + tipCents; - const invoiceStatus = Math.random() < 0.95 ? "paid" as const : "pending" as const; + const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; invoiceBatch.push({ @@ -458,7 +502,7 @@ async function seed() { status: invoiceStatus, paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, paidAt, - notes: Math.random() < 0.05 ? "Added extra service at checkout" : null, + notes: rand() < 0.05 ? "Added extra service at checkout" : null, }); // Line item