abac9dfe6c
- 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>
294 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|