GRO-1949: add behavioral and skin medicalAlertPool types, deterministic seeding for TestCooper/TestRocky #109

Merged
Scrubs McBarkley merged 6 commits from fleaflicker/gro-medical-alert-types-behavioral-skin into dev 2026-05-30 04:12:06 +00:00
4 changed files with 270 additions and 108 deletions
+3 -1
View File
@@ -32,7 +32,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @groombook/api typecheck
run: |
pnpm --filter @groombook/api typecheck
pnpm --filter @groombook/db typecheck
- name: Lint
run: pnpm --filter @groombook/api lint
@@ -178,6 +178,9 @@ vi.mock("../db/index.js", () => {
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
let selectedColumns: Record<string, Record<string, unknown>> = {};
function makeChainable(rows: unknown[]) {
const arr = rows as unknown[];
return new Proxy(arr, {
@@ -188,25 +191,67 @@ vi.mock("../db/index.js", () => {
if (prop === Symbol.iterator) {
return function* () { for (const v of target) yield v; };
}
if (prop === Symbol.asyncIterator) {
return async function* () { for (const v of target) yield v; };
}
// @ts-expect-error proxy
return target[prop];
},
});
}
// sql mock: returns an object with .as() so drizzle's select() can alias it
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
const asFn = (alias: string) => ({
sql: { queryChunks: [_strings[0]] },
fieldAlias: alias,
getSQL() { return this.sql; },
});
return { queryChunks: [queryString], as: asFn };
}
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
if (name === "pets") return makeChainable(mock.pets);
if (name === "appointments") return makeChainable(mock.appointments);
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
return makeChainable([]);
},
}),
select: (cols?: Record<string, unknown>) => {
selectedColumns = {};
if (cols) {
// Inspect cols to find sql-aliased expressions and their aliases
for (const [alias, expr] of Object.entries(cols)) {
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
// Detect count(*) queries
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
// Store count query intent — we'll resolve it in from()
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
selectedColumns["appointments"][alias] = { _isCountQuery: true };
}
}
}
}
return {
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
const tableCols = selectedColumns[name] || {};
// If this table has a count query, return computed count result
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
typeof v === "object" && v !== null && "_isCountQuery" in v
);
if (countQueryEntry) {
const [countAlias] = countQueryEntry;
const count = (name === "appointments" ? mock.appointments : [])
.filter((row: Record<string, unknown>) => row.status === "completed").length;
return makeChainable([{ [countAlias]: count }]);
}
if (name === "pets") return makeChainable(mock.pets);
if (name === "appointments") return makeChainable(mock.appointments);
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
return makeChainable([]);
},
};
},
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
@@ -222,7 +267,7 @@ vi.mock("../db/index.js", () => {
exists: vi.fn(() => true),
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
or: vi.fn((a: unknown, b: unknown) => [a, b]),
sql: vi.fn((str: string) => str),
sql: sqlMock,
};
});
+179 -95
View File
@@ -270,6 +270,10 @@ const medicalAlertPool: MedicalAlert[] = [
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
{ id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" },
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
];
const preferredCutPool: string[] = [
@@ -287,8 +291,8 @@ const preferredCutPool: string[] = [
"Full Groom",
];
type CoatType = schema.coatTypeEnum.enumValues[number];
type PetSizeCategory = schema.petSizeCategoryEnum.enumValues[number];
type CoatType = (typeof schema.coatTypeEnum.enumValues)[number];
type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number];
const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"];
@@ -385,78 +389,19 @@ const servicesDef = [
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
];
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
/**
* Seeds only the minimal known users for prod/demo environments.
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
* Idempotent: skips creation if records already exist.
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
*
* In the full seed path this must run AFTER random staff are created so the
* deterministic upserts land on the correct rows (groomers referenced by the
* UAT test-client appointment logic use groomers[0] etc.).
*
* In seedKnownUsers() this replaces the inline UAT-staff block.
*/
async function seedKnownUsers() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
console.log("Seeding known users (prod/demo mode)...\n");
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
// ── Staff: Demo Manager ──
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
.limit(1);
if (existingStaff) {
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: KNOWN_STAFF_ID,
name: "Demo Manager",
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
}
// ── Staff: SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL;
if (adminEmail) {
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
const [existingAdmin] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, adminEmail))
.limit(1);
if (existingAdmin) {
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
});
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
}
}
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
if (uatSuperOidcSub) {
@@ -624,6 +569,141 @@ async function seedKnownUsers() {
}
}
// ── Client: UAT Customer ─────────────────────────────────────────────────────
// Only uat-customer is a real end-user who needs a clients row.
// uat-groomer and uat-super are staff — they have staff records, not client records.
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const [uatCustomerRow] = await db
.select()
.from(schema.clients)
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
.limit(1);
let uatCustomerClientId: string;
if (uatCustomerRow) {
uatCustomerClientId = uatCustomerRow.id;
console.log(`✓ UAT Customer client record already exists — skipping`);
} else {
const [created] = await db
.insert(schema.clients)
.values({
id: UAT_CUSTOMER_ID,
email: "uat-customer@groombook.dev",
name: "UAT Customer",
phone: "555-0102",
address: "1 UAT Lane, Test City, CA 90210",
})
.returning();
uatCustomerClientId = created!.id;
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
}
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
const uatCustomerPets = [
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
];
for (const pet of uatCustomerPets) {
const [existing] = await db
.select()
.from(schema.pets)
.where(eq(schema.pets.id, pet.id))
.limit(1);
if (existing) {
console.log(`✓ UAT Pet '${existing.name}' already exists — skipping`);
} else {
await db.insert(schema.pets).values({
id: pet.id,
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
});
console.log(`✓ Created UAT pet '${pet.name}'`);
}
}
}
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
/**
* Seeds only the minimal known users for prod/demo environments.
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
* Idempotent: skips creation if records already exist.
*/
async function seedKnownUsers() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
console.log("Seeding known users (prod/demo mode)...\n");
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
// ── Staff: Demo Manager ──
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
.limit(1);
if (existingStaff) {
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: KNOWN_STAFF_ID,
name: "Demo Manager",
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
}
// ── Staff: SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL;
if (adminEmail) {
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
const [existingAdmin] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, adminEmail))
.limit(1);
if (existingAdmin) {
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
});
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
}
}
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
// and the full seed() UAT branch.
await seedUatStaffAccounts(db);
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -790,30 +870,10 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
}
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
// Must run AFTER random staff are created so upserts land correctly.
await seedUatStaffAccounts(db);
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
@@ -906,6 +966,14 @@ async function seed() {
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// Deterministic alerts for UAT AC pets
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
// Other UAT pets: random
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
@@ -1002,6 +1070,14 @@ async function seed() {
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// Deterministic alerts for UAT AC pets
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
// Other UAT pets: random
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
@@ -1025,6 +1101,14 @@ async function seed() {
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// Deterministic alerts for UAT AC pets
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
// Other UAT pets: random
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
@@ -6,6 +6,10 @@ const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const CLIENT_EMAIL = "alice@example.com";
const CLIENT_NAME = "Alice Smith";
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
const UAT_CUSTOMER_NAME = "UAT Customer";
const BETTER_AUTH_SESSION = {
user: {
id: "auth-user-001",
@@ -163,6 +167,33 @@ describe("POST /portal/session-from-auth", () => {
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
const uatAuthSession = {
user: {
id: "auth-user-uat-customer",
email: UAT_CUSTOMER_EMAIL,
name: UAT_CUSTOMER_NAME,
},
session: {
id: "ba-session-uat-customer",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
mockGetSession.mockResolvedValue(uatAuthSession);
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toHaveProperty("sessionId");
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
expect(insertedSession).not.toBeNull();
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 503 when auth is not configured", async () => {
mockGetAuth.mockImplementation(() => {
throw new Error("Auth not initialized");