Set up unit testing infrastructure
- Extract slot generation logic into apps/api/src/lib/slots.ts for testability - Add 8 unit tests covering slot generation edge cases (overlap, multi-groomer, boundary) - Add @testing-library/react + jsdom to apps/web; configure vitest with jsdom environment - Add 4 component tests for App navigation rendering and active-link highlighting - Remove passWithNoTests: true from both vitest configs; add coverage thresholds Closes #39 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@vitest/coverage-v8": "^3.0.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
generateAvailableSlots,
|
||||||
|
BUSINESS_START_HOUR,
|
||||||
|
BUSINESS_END_HOUR,
|
||||||
|
} from "../lib/slots.js";
|
||||||
|
|
||||||
|
const DATE = "2026-03-18";
|
||||||
|
const G1 = "groomer-1";
|
||||||
|
const G2 = "groomer-2";
|
||||||
|
|
||||||
|
function utc(h: number, m = 0): Date {
|
||||||
|
const d = new Date(`${DATE}T00:00:00Z`);
|
||||||
|
d.setUTCHours(h, m, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateAvailableSlots", () => {
|
||||||
|
it("returns slots within business hours", () => {
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1],
|
||||||
|
booked: [],
|
||||||
|
});
|
||||||
|
expect(slots.length).toBeGreaterThan(0);
|
||||||
|
slots.forEach((s) => {
|
||||||
|
const h = new Date(s).getUTCHours();
|
||||||
|
expect(h).toBeGreaterThanOrEqual(BUSINESS_START_HOUR);
|
||||||
|
expect(h).toBeLessThan(BUSINESS_END_HOUR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct count of 60-min slots across 8-hour window", () => {
|
||||||
|
// 09:00–17:00 = 8 hours → 8 one-hour slots
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1],
|
||||||
|
booked: [],
|
||||||
|
});
|
||||||
|
expect(slots).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no groomers", () => {
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [],
|
||||||
|
booked: [],
|
||||||
|
});
|
||||||
|
expect(slots).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes slots blocked by a booking", () => {
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1],
|
||||||
|
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||||
|
});
|
||||||
|
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||||
|
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps slot available when only the other groomer is booked", () => {
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1, G2],
|
||||||
|
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||||
|
});
|
||||||
|
// G2 is free at 09:00 so slot should still appear
|
||||||
|
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes a slot only when ALL groomers are booked", () => {
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1, G2],
|
||||||
|
booked: [
|
||||||
|
{ staffId: G1, startTime: utc(9), endTime: utc(10) },
|
||||||
|
{ staffId: G2, startTime: utc(9), endTime: utc(10) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly handles a booking that partially overlaps a slot", () => {
|
||||||
|
// Booking 09:30–10:30 should block the 09:00 and 10:00 slots for G1
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 60,
|
||||||
|
groomerIds: [G1],
|
||||||
|
booked: [{ staffId: G1, startTime: utc(9, 30), endTime: utc(10, 30) }],
|
||||||
|
});
|
||||||
|
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||||
|
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||||
|
expect(slots).toContain(new Date(`${DATE}T11:00:00.000Z`).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not generate a slot that would exceed business hours end", () => {
|
||||||
|
// 30-min slots: last valid start is 16:30 (ends at 17:00)
|
||||||
|
const slots = generateAvailableSlots({
|
||||||
|
dateStr: DATE,
|
||||||
|
durationMinutes: 30,
|
||||||
|
groomerIds: [G1],
|
||||||
|
booked: [],
|
||||||
|
});
|
||||||
|
const last = slots[slots.length - 1];
|
||||||
|
expect(last).toBeDefined();
|
||||||
|
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||||
|
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Business hours slot generation — pure utility, no DB dependencies.
|
||||||
|
* Extracted so it can be unit tested independently of the route layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BUSINESS_START_HOUR = 9; // UTC
|
||||||
|
export const BUSINESS_END_HOUR = 17; // UTC
|
||||||
|
|
||||||
|
export interface BookedSlot {
|
||||||
|
staffId: string | null;
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all available appointment start times for a given date,
|
||||||
|
* returning only slots where at least one groomer is free.
|
||||||
|
*/
|
||||||
|
export function generateAvailableSlots({
|
||||||
|
dateStr,
|
||||||
|
durationMinutes,
|
||||||
|
groomerIds,
|
||||||
|
booked,
|
||||||
|
}: {
|
||||||
|
dateStr: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
groomerIds: string[];
|
||||||
|
booked: BookedSlot[];
|
||||||
|
}): string[] {
|
||||||
|
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||||
|
|
||||||
|
const durationMs = durationMinutes * 60_000;
|
||||||
|
const slots: string[] = [];
|
||||||
|
let slotStart = dayStart.getTime();
|
||||||
|
|
||||||
|
while (slotStart + durationMs <= dayEnd.getTime()) {
|
||||||
|
const slotEnd = slotStart + durationMs;
|
||||||
|
const hasGroomer = groomerIds.some(
|
||||||
|
(groomerId) =>
|
||||||
|
!booked.some(
|
||||||
|
(a) =>
|
||||||
|
a.staffId === groomerId &&
|
||||||
|
a.startTime.getTime() < slotEnd &&
|
||||||
|
a.endTime.getTime() > slotStart
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||||
|
slotStart += durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
+11
-21
@@ -15,13 +15,14 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import {
|
||||||
|
generateAvailableSlots,
|
||||||
|
BUSINESS_START_HOUR,
|
||||||
|
BUSINESS_END_HOUR,
|
||||||
|
} from "../lib/slots.js";
|
||||||
|
|
||||||
export const bookRouter = new Hono();
|
export const bookRouter = new Hono();
|
||||||
|
|
||||||
// Business hours (UTC) — 09:00–17:00
|
|
||||||
const BUSINESS_START_HOUR = 9;
|
|
||||||
const BUSINESS_END_HOUR = 17;
|
|
||||||
|
|
||||||
// ─── GET /api/book/services ─────────────────────────────────────────────────
|
// ─── GET /api/book/services ─────────────────────────────────────────────────
|
||||||
// Public: list active services for the booking flow
|
// Public: list active services for the booking flow
|
||||||
|
|
||||||
@@ -86,23 +87,12 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const durationMs = service.durationMinutes * 60_000;
|
const slots = generateAvailableSlots({
|
||||||
const slots: string[] = [];
|
dateStr,
|
||||||
let slotStart = dayStart.getTime();
|
durationMinutes: service.durationMinutes,
|
||||||
|
groomerIds: groomers.map((g) => g.id),
|
||||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
booked,
|
||||||
const slotEnd = slotStart + durationMs;
|
});
|
||||||
const hasGroomer = groomers.some(({ id: groomerId }) =>
|
|
||||||
!booked.some(
|
|
||||||
(a) =>
|
|
||||||
a.staffId === groomerId &&
|
|
||||||
a.startTime.getTime() < slotEnd &&
|
|
||||||
a.endTime.getTime() > slotStart
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
|
||||||
slotStart += durationMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(slots);
|
return c.json(slots);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
passWithNoTests: true,
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: ["src/lib/**"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,10 +18,15 @@
|
|||||||
"react-router-dom": "^7.1.2"
|
"react-router-dom": "^7.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.6",
|
"@types/react": "^19.0.6",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within, act } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { App } from "../App.js";
|
||||||
|
|
||||||
|
// Prevent fetch errors from page components loading data on mount
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [],
|
||||||
|
} as unknown as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderApp(route = "/") {
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[route]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return screen.getByRole("navigation");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App navigation", () => {
|
||||||
|
it("renders the Groom Book brand", async () => {
|
||||||
|
const nav = await renderApp();
|
||||||
|
expect(within(nav).getByText("Groom Book")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Book CTA button", async () => {
|
||||||
|
const nav = await renderApp();
|
||||||
|
expect(within(nav).getByText("Book")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all primary nav links", async () => {
|
||||||
|
const nav = await renderApp();
|
||||||
|
const expectedLinks = [
|
||||||
|
"Appointments",
|
||||||
|
"Clients",
|
||||||
|
"Services",
|
||||||
|
"Staff",
|
||||||
|
"Invoices",
|
||||||
|
"Group Bookings",
|
||||||
|
"Reports",
|
||||||
|
];
|
||||||
|
expectedLinks.forEach((label) => {
|
||||||
|
expect(within(nav).getByText(label)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the active route link", async () => {
|
||||||
|
const nav = await renderApp("/clients");
|
||||||
|
const clientsLink = within(nav).getByText("Clients");
|
||||||
|
// Active links use fontWeight 600
|
||||||
|
expect(clientsLink).toHaveStyle({ fontWeight: "600" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
passWithNoTests: true,
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
globals: true,
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: ["src/**"],
|
||||||
|
exclude: ["src/test/**", "src/main.tsx"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 50,
|
||||||
|
functions: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+777
-3
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user