import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; // ─── Mock staff fixtures ────────────────────────────────────────────────────── 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", }; // ─── Shared mutable DB state ────────────────────────────────────────────────── const PET_ID = "pet-uuid-1234"; const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`; let dbPetRow: Record | null; function resetDb() { dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null }; } // ─── Module mocks ───────────────────────────────────────────────────────────── vi.mock("@groombook/db", () => { const pets = new Proxy( { _name: "pets" }, { get(t, p) { return p === "_name" ? "pets" : {}; } } ); return { getDb: () => ({ select: () => ({ from: () => ({ where: () => (dbPetRow ? [dbPetRow] : []), }), }), update: () => ({ set: () => ({ where: () => ({ returning: () => (dbPetRow ? [{ ...dbPetRow }] : []), }), }), }), }), pets, eq: vi.fn(), }; }); vi.mock("../lib/s3.js", () => ({ getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"), getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"), deleteObject: vi.fn().mockResolvedValue(undefined), })); // ─── Import after mocks are set up ─────────────────────────────────────────── const { petsRouter } = await import("../routes/pets.js"); // ─── App builder ───────────────────────────────────────────────────────────── function buildApp(staffRow: StaffRow) { const app = new Hono(); app.use("*", async (c, next) => { c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" }); c.set("staff", staffRow); await next(); }); app.route("/pets", petsRouter); return app; } // ─── Reset before each test ─────────────────────────────────────────────────── beforeEach(() => { resetDb(); vi.clearAllMocks(); }); // ─── POST /:petId/photo/upload-url ─────────────────────────────────────────── describe("POST /pets/:petId/photo/upload-url", () => { it("returns presigned upload URL and object key for valid image contentType", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }), }); expect(res.status).toBe(200); const body = (await res.json()) as { uploadUrl: string; key: string }; expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put"); expect(body.key).toMatch(/^pets\//); expect(body.key).toContain(PET_ID); }); it("rejects non-image contentType with 400", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "application/pdf", fileSizeBytes: 1024 }), }); expect(res.status).toBe(400); }); it("rejects image/svg+xml with 400 (allowlist enforcement)", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "image/svg+xml", fileSizeBytes: 1024 }), }); expect(res.status).toBe(400); }); it("rejects fileSizeBytes over 5 MB with 400", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 6 * 1024 * 1024 }), }); expect(res.status).toBe(400); }); it("returns 404 when pet does not exist", async () => { dbPetRow = null; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }), }); expect(res.status).toBe(404); }); it("allows groomers to request an upload URL", async () => { const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "image/png", fileSizeBytes: 1024 }), }); expect(res.status).toBe(200); }); }); // ─── POST /:petId/photo/confirm ─────────────────────────────────────────────── describe("POST /pets/:petId/photo/confirm", () => { it("confirms upload and returns ok: true", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: PHOTO_KEY }), }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); it("returns 400 when key is missing", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); it("returns 404 when pet does not exist", async () => { dbPetRow = null; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: PHOTO_KEY }), }); expect(res.status).toBe(404); }); it("returns 400 when key does not belong to the pet", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "pets/other-pet-id/1700000000000.jpg" }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/invalid key/i); }); it("deletes old photo from storage when re-uploading", async () => { const { deleteObject } = await import("../lib/s3.js"); const oldKey = `pets/${PET_ID}/old.jpg`; dbPetRow = { ...dbPetRow!, photoKey: oldKey }; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: PHOTO_KEY }), }); expect(res.status).toBe(200); expect(deleteObject).toHaveBeenCalledWith(oldKey); }); }); // ─── DELETE /:petId/photo ──────────────────────────────────────────────────── describe("DELETE /pets/:petId/photo", () => { it("returns 404 with 'no photo' message when pet has no photo", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/no photo/i); }); it("deletes photo and returns ok: true when photo exists", async () => { dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); it("returns 404 when pet does not exist", async () => { dbPetRow = null; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" }); expect(res.status).toBe(404); }); }); // ─── GET /:petId/photo ──────────────────────────────────────────────────────── describe("GET /pets/:petId/photo", () => { it("returns 404 when pet has no photo", async () => { const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`); expect(res.status).toBe(404); }); it("returns presigned GET URL when photo exists", async () => { dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`); expect(res.status).toBe(200); const body = (await res.json()) as { url: string; photoKey: string }; expect(body.url).toBe("https://storage.example.com/presigned-get"); expect(body.photoKey).toBe(PHOTO_KEY); }); it("returns 404 when pet does not exist", async () => { dbPetRow = null; const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/photo`); expect(res.status).toBe(404); }); it("groomer can read photo URL", async () => { dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY }; const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/photo`); expect(res.status).toBe(200); }); });