From 1f50fdff54e65edc61cb956fdded55f0399ad8e4 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 23:43:47 +0000 Subject: [PATCH 1/2] test(db): add unit tests for test factories (GitHub #94) Tests cover resetFactoryCounters(), counter determinism, override merging, and compile-time enforcement of required fields on buildAppointment. All 16 new tests pass (92 total). Co-Authored-By: Paperclip --- apps/api/package.json | 4 +- apps/api/src/__tests__/factories.test.ts | 216 +++++++++++++++++++++++ pnpm-lock.yaml | 7 +- 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/__tests__/factories.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index b032eaf..d755d49 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,11 +27,11 @@ "@types/node": "^22.10.7", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", - "@vitest/coverage-v8": "^3.0.4", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", - "vitest": "^3.0.4" + "vitest": "^3.2.4" } } diff --git a/apps/api/src/__tests__/factories.test.ts b/apps/api/src/__tests__/factories.test.ts new file mode 100644 index 0000000..bdb7fad --- /dev/null +++ b/apps/api/src/__tests__/factories.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + resetFactoryCounters, + buildStaff, + buildClient, + buildPet, + buildService, + buildAppointment, +} from "@groombook/db/factories"; + +describe("resetFactoryCounters", () => { + it("resets all counters so IDs restart from 1", () => { + buildStaff(); + buildStaff(); + buildClient(); + resetFactoryCounters(); + + const staff = buildStaff(); + const client = buildClient(); + + expect(staff.id).toBe("staff-1"); + expect(client.id).toBe("client-1"); + }); + + it("resets counters for every entity type", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + resetFactoryCounters(); + + expect(buildStaff().id).toBe("staff-1"); + expect(buildClient().id).toBe("client-1"); + expect(buildService().id).toBe("service-1"); + const c = buildClient(); + expect(buildPet({ clientId: c.id }).id).toBe("pet-1"); + const s = buildService(); + const p = buildPet({ clientId: c.id }); + expect( + buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id + ).toBe("appointment-1"); + }); +}); + +describe("counter determinism", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("increments staff IDs sequentially", () => { + expect(buildStaff().id).toBe("staff-1"); + expect(buildStaff().id).toBe("staff-2"); + expect(buildStaff().id).toBe("staff-3"); + }); + + it("increments client IDs sequentially", () => { + expect(buildClient().id).toBe("client-1"); + expect(buildClient().id).toBe("client-2"); + }); + + it("increments pet IDs sequentially", () => { + const client = buildClient(); + expect(buildPet({ clientId: client.id }).id).toBe("pet-1"); + expect(buildPet({ clientId: client.id }).id).toBe("pet-2"); + }); + + it("increments service IDs sequentially", () => { + expect(buildService().id).toBe("service-1"); + expect(buildService().id).toBe("service-2"); + }); + + it("increments appointment IDs sequentially", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" }; + + expect(buildAppointment(required).id).toBe("appointment-1"); + expect(buildAppointment(required).id).toBe("appointment-2"); + }); + + it("each entity type maintains its own independent counter", () => { + buildStaff(); + buildStaff(); + buildClient(); + + // staff counter is at 2; client counter is at 1 + expect(buildStaff().id).toBe("staff-3"); + expect(buildClient().id).toBe("client-2"); + }); +}); + +describe("override merging", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("buildStaff applies overrides over defaults", () => { + const staff = buildStaff({ role: "manager", name: "Boss" }); + + expect(staff.role).toBe("manager"); + expect(staff.name).toBe("Boss"); + expect(staff.id).toBe("staff-1"); + expect(staff.active).toBe(true); // default preserved + }); + + it("buildStaff id override is respected without disrupting the counter", () => { + const staff = buildStaff({ id: "custom-id" }); + + expect(staff.id).toBe("custom-id"); + // counter still ticked — next call gets staff-2 + expect(buildStaff().id).toBe("staff-2"); + }); + + it("buildClient applies overrides over defaults", () => { + const client = buildClient({ name: "Alice Smith", emailOptOut: true }); + + expect(client.name).toBe("Alice Smith"); + expect(client.emailOptOut).toBe(true); + expect(client.status).toBe("active"); // default preserved + }); + + it("buildPet merges overrides and sets clientId from required arg", () => { + const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" }); + + expect(pet.clientId).toBe("client-99"); + expect(pet.name).toBe("Fluffy"); + expect(pet.breed).toBe("Poodle"); + expect(pet.species).toBe("Dog"); // default preserved + }); + + it("buildService applies overrides over defaults", () => { + const service = buildService({ basePriceCents: 9900, active: false }); + + expect(service.basePriceCents).toBe(9900); + expect(service.active).toBe(false); + expect(service.durationMinutes).toBe(60); // default preserved + }); + + it("buildAppointment applies overrides over defaults", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + status: "confirmed", + notes: "allergic to lavender", + }); + + expect(appt.status).toBe("confirmed"); + expect(appt.notes).toBe("allergic to lavender"); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + // defaults preserved + expect(appt.batherStaffId).toBeNull(); + expect(appt.priceCents).toBeNull(); + }); +}); + +describe("buildAppointment required fields", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("produces a fully-populated AppointmentRow", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + expect(appt.id).toBeDefined(); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + expect(appt.serviceId).toBe(service.id); + expect(appt.staffId).toBe("staff-1"); + expect(appt.startTime).toBeInstanceOf(Date); + expect(appt.endTime).toBeInstanceOf(Date); + expect(appt.status).toBe("scheduled"); + expect(appt.batherStaffId).toBeNull(); + expect(appt.seriesId).toBeNull(); + expect(appt.seriesIndex).toBeNull(); + expect(appt.groupId).toBeNull(); + expect(appt.notes).toBeNull(); + expect(appt.priceCents).toBeNull(); + expect(appt.createdAt).toBeInstanceOf(Date); + expect(appt.updatedAt).toBeInstanceOf(Date); + }); + + // TypeScript compile-time enforcement: omitting any required field produces a type error. + // The overrides parameter type is `Partial & { clientId: string; petId: string; serviceId: string; staffId: string }`. + // The test below verifies the type signature is correct by using @ts-expect-error. + it("type error when required fields are missing — compile-time enforcement", () => { + // @ts-expect-error clientId is required + buildAppointment({ petId: "p", serviceId: "s", staffId: "st" }); + // @ts-expect-error petId is required + buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" }); + // @ts-expect-error serviceId is required + buildAppointment({ clientId: "c", petId: "p", staffId: "st" }); + // @ts-expect-error staffId is required + buildAppointment({ clientId: "c", petId: "p", serviceId: "s" }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced239b..516de9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: specifier: ^6.4.17 version: 6.4.23 '@vitest/coverage-v8': - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) eslint: specifier: ^9.18.0 @@ -66,7 +66,7 @@ importers: specifier: ^8.20.0 version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) apps/e2e: @@ -166,6 +166,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 + vitest: + specifier: ^3.0.4 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) packages/types: devDependencies: From 891cc39ae141bdf3bfab23d64104fdf3183ca2bc Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 23:47:15 +0000 Subject: [PATCH 2/2] fix: remove stale vitest entry from packages/db lockfile vitest was erroneously added to the packages/db importer in pnpm-lock.yaml during factory test setup, but packages/db/package.json does not declare vitest as a dependency. This caused CI to fail with ERR_PNPM_OUTDATED_LOCKFILE. Co-Authored-By: Paperclip --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 516de9a..cb9f67b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,9 +166,6 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 - vitest: - specifier: ^3.0.4 - version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) packages/types: devDependencies: