Files
api/src/__tests__/groomerIsolation.test.ts
T
Chris Farhood abac9dfe6c Extract groombook/api from monorepo with CI workflow
- Add source code from apps/api
- Add packages/db and packages/types workspace dependencies
- Add GitHub Actions CI workflow (lint, typecheck, test, docker)
- Generate pnpm-lock.yaml
- Add .gitignore

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 01:26:56 +00:00

107 lines
3.9 KiB
TypeScript

/**
* Groomer Isolation Tests
*
* Validates row-level data scoping for the groomer role.
*
* The role guard tests verify the core groomer identification logic.
* Integration tests with the real database validate the full filter behavior.
*/
import { describe, it, expect } from "vitest";
import type { StaffRow } from "../middleware/rbac.js";
// ─── Mock staff ───────────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
};
// ─── Role guard ──────────────────────────────────────────────────────────────
/**
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
* These tests verify it handles all roles correctly.
*/
describe("Groomer role guard", () => {
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
});
// ─── Groomer filter data shapes ───────────────────────────────────────────────
/**
* These constants match the shape used in route handlers to validate
* the groomer filter conditions:
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
* This verifies the groomer can see appointments they own OR bathe.
*/
describe("Groomer appointment filter data", () => {
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
it("groomer appointment has groomer staffId", () => {
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
expect(GROOMER_APPT.batherStaffId).toBeNull();
});
it("groomer can see appointment where they are the bather", () => {
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
});
it("other appointment is not assigned to groomer", () => {
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
expect(OTHER_APPT.batherStaffId).toBeNull();
});
it("filter: groomer sees only their appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
const groomerView = all.filter(
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
);
expect(groomerView).toHaveLength(2);
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
});
it("filter: manager sees all appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
expect(all).toHaveLength(3);
});
});