feat: deterministic seed, impersonation migration, test factories (GRO-110) #92

Merged
groombook-engineer[bot] merged 1 commits from feat/dev-data-strategy-gro-110 into main 2026-03-21 23:18:36 +00:00
9 changed files with 327 additions and 42 deletions
+4 -14
View File
@@ -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" };
+1
View File
@@ -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"),
},
},
+2 -1
View File
@@ -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",
@@ -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
);
@@ -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
}
]
}
+5
View File
@@ -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"
},
+141
View File
@@ -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<string, number> = {};
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> = {}): 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> = {}): 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<PetRow> & { 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> = {}): 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<AppointmentRow> & { 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 };
}
+70
View File
@@ -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);
});
+71 -27
View File
@@ -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<T>(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<T>(arr: T[]): T {
return arr[Math.floor(rand() * arr.length)]!;
}
/** Return n distinct random elements from an array. */
function pickN<T>(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<string, string[]>();
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