90abb28a0d
- factories.ts: add photoKey/photoUploadedAt null defaults to buildPet (TS regression fix)
- s3.ts: lazy singleton S3Client to avoid re-instantiation per call
- routes/pets.ts: server-side 5MB file size limit, explicit content-type allowlist (drops image/svg+xml etc), validate confirm key ownership against pets/${petId}/ prefix, delete old S3 object on re-upload, fix RBAC comment on DELETE photo
- PetPhotoUpload.tsx: bypass canvas resize for GIFs (preserves animation), pass fileSizeBytes in upload-url request
- Add PetPhotoDisplay.test.tsx: 7 tests covering fetch states, placeholder, refetch on petId change, custom size
- Add PetPhotoUpload.test.tsx: 8 tests covering idle state, type validation, upload flow, progress, GIF bypass
- Update petPhotos.test.ts: add SVG rejection, 5MB limit, key ownership, and old-photo deletion tests (18 total)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("PetPhotoDisplay", () => {
|
|
it("shows loading skeleton while fetching", () => {
|
|
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
|
|
|
|
render(<PetPhotoDisplay petId="pet-1" />);
|
|
|
|
expect(screen.getByLabelText("Loading photo…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders photo img when fetch returns a URL", async () => {
|
|
global.fetch = vi.fn(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ url: "https://storage.test/pet-1/photo.jpg" }),
|
|
} as Response)
|
|
) as unknown as typeof fetch;
|
|
|
|
render(<PetPhotoDisplay petId="pet-1" />);
|
|
|
|
const img = await screen.findByRole("img", { name: "Pet photo" });
|
|
expect(img).toHaveAttribute("src", "https://storage.test/pet-1/photo.jpg");
|
|
});
|
|
|
|
it("shows paw placeholder when API returns 404", async () => {
|
|
global.fetch = vi.fn(() =>
|
|
Promise.resolve({ ok: false, status: 404 } as Response)
|
|
) as unknown as typeof fetch;
|
|
|
|
render(<PetPhotoDisplay petId="pet-1" />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByLabelText("Loading photo…")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows paw placeholder when fetch rejects (network error)", async () => {
|
|
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
|
|
|
|
render(<PetPhotoDisplay petId="pet-1" />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows paw placeholder on non-404 error status", async () => {
|
|
global.fetch = vi.fn(() =>
|
|
Promise.resolve({ ok: false, status: 500 } as Response)
|
|
) as unknown as typeof fetch;
|
|
|
|
render(<PetPhotoDisplay petId="pet-1" />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("refetches when petId changes", async () => {
|
|
const fetchMock = vi.fn((url: string) => {
|
|
const petId = (url as string).match(/\/api\/pets\/([^/]+)\/photo/)?.[1];
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ url: `https://storage.test/${petId}/photo.jpg` }),
|
|
} as Response);
|
|
}) as unknown as typeof fetch;
|
|
global.fetch = fetchMock;
|
|
|
|
const { rerender } = render(<PetPhotoDisplay petId="pet-1" />);
|
|
await screen.findByRole("img");
|
|
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/photo");
|
|
|
|
rerender(<PetPhotoDisplay petId="pet-2" />);
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/photo");
|
|
});
|
|
});
|
|
|
|
it("applies custom size prop to container", async () => {
|
|
global.fetch = vi.fn(() =>
|
|
Promise.resolve({ ok: false, status: 404 } as Response)
|
|
) as unknown as typeof fetch;
|
|
|
|
const { container } = render(<PetPhotoDisplay petId="pet-1" size={96} />);
|
|
|
|
await screen.findByLabelText("No photo");
|
|
const div = container.firstChild as HTMLElement;
|
|
expect(div).toHaveStyle({ width: "96px", height: "96px" });
|
|
});
|
|
});
|