Files
api/src/__tests__/petPhotos.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

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("@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<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);
});
});