diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dfb785b..0f05f7c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index ac523cc..5bfc818 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,4 +50,5 @@ CMD ["pnpm", "--filter", "@groombook/db", "seed"] # Reset stage — drops all tables, re-runs migrations, and re-seeds FROM builder AS reset +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate CMD ["pnpm", "--filter", "@groombook/db", "reset"] diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 166cf68..2cb3bab 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -103,6 +103,18 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) | | TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) | +#### Seed Data Verification (GRO-1898) + +> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) | +| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null | +| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity | +| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" | +| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" | + ### 4.4 Appointment Scheduling | # | Scenario | Steps | Expected | diff --git a/apps/api/src/__tests__/petProfileSummary.test.ts b/apps/api/src/__tests__/petProfileSummary.test.ts index 38b138c..f7e5686 100644 --- a/apps/api/src/__tests__/petProfileSummary.test.ts +++ b/apps/api/src/__tests__/petProfileSummary.test.ts @@ -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> = {}; + 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) => { + 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).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((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) => 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, }; }); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 566da17..fc65098 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -20,6 +20,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; +import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -252,6 +253,38 @@ const appointmentNotes = [ "Client running late, pushed start by 15min", ]; +const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9]; + +const temperamentFlags = [ + [], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"], + ["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"], + ["friendly", "nippy"], ["anxious", "territorial"], +]; + +const medicalAlertsList = [ + [] as MedicalAlert[], + [] as MedicalAlert[], + [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], + [{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }], + [{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }], + [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], + [{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }], + [{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }], + [{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }], + [{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }], + [{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }], +]; + +const preferredCutsList = [ + [], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"], + ["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"], + ["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"], + ["Breed Standard", "Sanitary Trim"], ["Summer Shave"], + ["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"], +]; + +const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"]; + const visitLogNotes = [ null, null, "Coat in great condition", @@ -872,6 +905,11 @@ async function seed() { cutStyle: pick(cutStyles), shampooPreference: pick(shampoos), specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, + coatType: pick(coatTypes), + temperamentScore: pick(temperamentScores), + temperamentFlags: pick(temperamentFlags), + medicalAlerts: pick(medicalAlertsList), + preferredCuts: pick(preferredCutsList), customFields: {}, image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), }); @@ -907,6 +945,11 @@ async function seed() { cutStyle: pet.cutStyle, shampooPreference: pet.shampooPreference, specialCareNotes: pet.specialCareNotes, + coatType: pet.coatType, + temperamentScore: pet.temperamentScore, + temperamentFlags: pet.temperamentFlags, + medicalAlerts: pet.medicalAlerts, + preferredCuts: pet.preferredCuts, customFields: pet.customFields, image: pet.image, }, @@ -929,13 +972,18 @@ async function seed() { petId: string; petName: string; petBreed: string; + petCoatType: string; + petTemperamentScore: number; + petTemperamentFlags: string[]; + petMedicalAlerts: MedicalAlert[]; + petPreferredCuts: string[]; } const uatClients: UatClient[] = [ - { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" }, - { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" }, - { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" }, - { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" }, - { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" }, + { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] }, + { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] }, + { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] }, + { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] }, + { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] }, ]; for (const uc of uatClients) { @@ -943,8 +991,26 @@ async function seed() { .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); await db.insert(schema.pets) - .values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) }) - .onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } }); + .values({ + id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, + weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), + coatType: uc.petCoatType, + temperamentScore: uc.petTemperamentScore, + temperamentFlags: uc.petTemperamentFlags, + medicalAlerts: uc.petMedicalAlerts, + preferredCuts: uc.petPreferredCuts, + image: pick(demoPetImages), + }) + .onConflictDoUpdate({ target: schema.pets.id, set: { + clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, + weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), + coatType: uc.petCoatType, + temperamentScore: uc.petTemperamentScore, + temperamentFlags: uc.petTemperamentFlags, + medicalAlerts: uc.petMedicalAlerts, + preferredCuts: uc.petPreferredCuts, + image: pick(demoPetImages), + } }); // Create one completed appointment for this client const apptId = uuid(); const svcIdx = 0; diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 79164fa..5e33c05 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -20,6 +20,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; +import type { MedicalAlert } from "@groombook/types"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -243,6 +244,55 @@ const groomingNotes = [ "Previous clipper burn — be gentle on belly", ]; +// ── Extended pet profile pools ───────────────────────────────────────────────── + +const temperamentFlagPool: string[] = [ + "friendly", + "anxious-with-strangers", + "good-with-kids", + "leash-reactive", + "vocal", + "high-energy", + "calm-on-table", + "treat-motivated", +]; + +const medicalAlertPool: MedicalAlert[] = [ + { id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" }, + { id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" }, + { id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" }, + { id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" }, + { id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" }, + { id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" }, + { id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" }, + { id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" }, + { id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" }, + { 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" }, +]; + +const preferredCutPool: string[] = [ + "Puppy Cut", + "Teddy Bear Cut", + "Lion Cut", + "Breed Standard", + "Summer Shave", + "Kennel Cut", + "Lamb Cut", + "Continental Clip", + "Sporting Clip", + "Sanitary Trim", + "Face & Feet Trim", + "Full Groom", +]; + +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"]; + const appointmentNotes = [ null, null, null, null, "Client requested extra brushing", @@ -853,6 +903,18 @@ async function seed() { specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, customFields: {}, image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), }); petRecords.push({ id: petId, clientId }); @@ -888,6 +950,12 @@ async function seed() { specialCareNotes: pet.specialCareNotes, customFields: pet.customFields, image: pet.image, + temperamentScore: pet.temperamentScore, + temperamentFlags: pet.temperamentFlags, + medicalAlerts: pet.medicalAlerts, + preferredCuts: pet.preferredCuts, + coatType: pet.coatType, + petSizeCategory: pet.petSizeCategory, }, }); } @@ -922,8 +990,52 @@ async function seed() { .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); await db.insert(schema.pets) - .values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) }) - .onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } }); + .values({ + id: uc.petId, + clientId: uc.id, + name: uc.petName, + species: "Dog", + breed: uc.petBreed, + weightKg: "25.00", + dateOfBirth: new Date("2021-03-15T00:00:00Z"), + image: pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { + clientId: uc.id, + name: uc.petName, + species: "Dog", + breed: uc.petBreed, + weightKg: "25.00", + dateOfBirth: new Date("2021-03-15T00:00:00Z"), + image: pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }, + }); // Create one completed appointment for this client const apptId = uuid(); const svcIdx = 0;