Files
api/apps/api/src/__tests__/petPhotos.test.ts
T
Chris Farhood f4995d987d fix: correct test mock paths from "./db" to "../db"
Fixes incorrect vi.mock paths that were causing tests to fail.
The mock path should match the import path in the route files.

This addresses the authProvider test mock path issue on PR #2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-12 19:54:29 +00:00

294 lines
11 KiB
TypeScript

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<string, unknown> | null;
function resetDb() {
dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null };
}
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("../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<AppEnv>();
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);
});
});