From 4df7d960206120585a193a732bb8d58b77ff6aaf Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 29 May 2026 15:24:33 +0000 Subject: [PATCH 1/3] fix(seed): use typeof on enum.enumValues for db build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS2749: enumValues is a value, not a type — wrap with typeof before indexing. Also extends Lint & Typecheck CI job to run pnpm --filter @groombook/db typecheck so this class of error is caught at lint time rather than Docker build time. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yml | 4 +++- packages/db/src/seed.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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/packages/db/src/seed.ts b/packages/db/src/seed.ts index d2d274b..5e33c05 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -287,8 +287,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"]; -- 2.52.0 From bf064b3adade67160a61602c81aab0fc3dd43282 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 29 May 2026 16:34:33 +0000 Subject: [PATCH 2/3] fix(test): mock db to handle sql count(*) queries and async iteration The petProfileSummary mock's sql tag returned a plain string instead of a proper Drizzle SQL object, so count(*) queries via .as("count") failed. Also added Symbol.asyncIterator support for for-await-of patterns used in the pets router. Fixes: GRO-1917 Co-Authored-By: Paperclip --- .../src/__tests__/petProfileSummary.test.ts | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) 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, }; }); -- 2.52.0 From 4a0dd5ed2a15dffc7757a3b571725422831d3488 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sat, 30 May 2026 02:52:33 +0000 Subject: [PATCH 3/3] fix(seed): add uat-customer client record for SSO bridge UAT (GRO-1935) - Add UAT Customer client row (id: c0000001-0000-0000-0000-000000000001) with email uat-customer@groombook.dev in seedKnownUsers() - Add two UAT Customer pets (UAT Pup Alpha, UAT Pup Beta) with stable IDs - Add test case covering 201 response with correct clientId/clientName for uat-customer SSO bridge flow - Explicit comment clarifying uat-groomer/uat-super are staff, not clients Co-Authored-By: Paperclip --- packages/db/src/seed.ts | 57 +++++++++++++++++++++ src/__tests__/portalSessionFromAuth.test.ts | 31 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 5e33c05..3e90a9d 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -624,6 +624,63 @@ 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}'`); + } + } + // ── 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. diff --git a/src/__tests__/portalSessionFromAuth.test.ts b/src/__tests__/portalSessionFromAuth.test.ts index 8448803..960e61e 100644 --- a/src/__tests__/portalSessionFromAuth.test.ts +++ b/src/__tests__/portalSessionFromAuth.test.ts @@ -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).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).reason).toBe("sso-bridge"); + }); + it("returns 503 when auth is not configured", async () => { mockGetAuth.mockImplementation(() => { throw new Error("Auth not initialized"); -- 2.52.0