test(02-04): add failing tests for posts router RED

- PHOTO-01: create single photo inserts Post + PostImage + fans out
- PHOTO-02: carousel limit rejects >10 images (Zod already validates)
- PHOTO-03: updateCaption forbidden for non-owner → FORBIDDEN
- PHOTO-04: delete calls prisma.post.delete (PostImages cascade via schema)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-07 12:42:49 +02:00
1 parent bdd676f19b
commit 5e71010e50
1 file changed
+194 -9
+194 -9
View File
@@ -1,5 +1,5 @@
/**
* Posts Unit Tests — Phase 2 Plan 02-01 (scaffold)
* Posts Unit Tests — Phase 2 Plan 02-04
*
* Covers: PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04
*
@@ -8,15 +8,48 @@
* Test: updateCaption forbidden — non-owner cannot edit (PHOTO-03)
* Test: delete cascades PostImages (PHOTO-04)
*
* All tests are it.todo stubs until Wave 1 implementation.
* No real DB or Redis calls — all mocked.
* Redis mocked via vi.mock('@/lib/redis') — no real Upstash calls.
* fanOutPost mocked via vi.mock('@/lib/feed-helpers').
*/
import { describe, it, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
// Mock server-only guard
vi.mock("server-only", () => ({}));
// Mock Redis — no real Upstash calls
vi.mock("@/lib/redis", () => ({
redis: {
zrange: vi.fn(),
zadd: vi.fn(),
pipeline: vi.fn(() => ({
zadd: vi.fn(),
exec: vi.fn().mockResolvedValue([]),
})),
},
}));
// Mock fanOutPost — we verify it is called with correct args
vi.mock("@/lib/feed-helpers", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/feed-helpers")>();
return {
...actual,
fanOutPost: vi.fn().mockResolvedValue(undefined),
};
});
// Mock Supabase storage — no real storage calls
vi.mock("@/lib/supabase-storage", () => ({
createSignedUploadUrl: vi.fn().mockResolvedValue({
signedUrl: "https://storage.example.com/signed",
path: "posts/owner-1/pet-1/uuid.jpg",
}),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
}));
// Mock Clerk server auth
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
@@ -27,16 +60,168 @@ vi.mock("@/lib/prisma", () => ({
prisma: {},
}));
describe("posts.create", () => {
it.todo("PHOTO-01 — create single photo inserts Post + PostImage with correct petId");
import { postsRouter } from "@/trpc/routers/posts";
import { fanOutPost } from "@/lib/feed-helpers";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
it.todo("PHOTO-02 — carousel limit rejects >10 images with BAD_REQUEST");
const mockFanOutPost = vi.mocked(fanOutPost);
// Helper to create a tRPC caller with mocked context
function createCaller(overrides?: {
userId?: string;
prisma?: ReturnType<typeof createMockPrisma>;
}) {
const mockPrisma = overrides?.prisma ?? createMockPrisma();
const ctx = {
userId: overrides?.userId ?? "owner-1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: mockPrisma as any,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { caller: postsRouter.createCaller(ctx as any), mockPrisma };
}
describe("posts.create", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("PHOTO-01 — create single photo inserts Post + PostImage with correct petId", async () => {
const { caller, mockPrisma } = createCaller();
// Pet belongs to owner-1
mockPrisma.pet.findUnique.mockResolvedValue({
id: "pet-1",
ownerId: "owner-1",
});
const mockPost = {
id: "post-1",
petId: "pet-1",
type: "PHOTO",
caption: "Hello world",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
images: [
{
id: "img-1",
postId: "post-1",
storageKey: "posts/owner-1/pet-1/uuid.jpg",
position: 0,
width: null,
height: null,
},
],
pet: { id: "pet-1", name: "Buddy", avatarKey: null },
milestone: null,
};
mockPrisma.post.create.mockResolvedValue(mockPost);
const result = await caller.create({
petId: "pet-1",
caption: "Hello world",
imageKeys: ["posts/owner-1/pet-1/uuid.jpg"],
});
// Post was created
expect(mockPrisma.post.create).toHaveBeenCalledOnce();
// Created with correct petId
expect(mockPrisma.post.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
petId: "pet-1",
type: "PHOTO",
caption: "Hello world",
}),
})
);
// Fan-out was called (PHOTO-01 — post appears in followers' feeds)
expect(mockFanOutPost).toHaveBeenCalledOnce();
expect(mockFanOutPost).toHaveBeenCalledWith(
expect.anything(), // redis
"post-1",
"pet-1",
expect.any(Date),
expect.anything() // prisma
);
// Result matches created post
expect(result.id).toBe("post-1");
});
it("PHOTO-02 — carousel limit rejects >10 images with BAD_REQUEST", async () => {
const { caller } = createCaller();
// 11 valid keys should be rejected by Zod before hitting the mutation handler
await expect(
caller.create({
petId: "pet-1",
caption: null,
imageKeys: Array.from(
{ length: 11 },
(_, i) => `posts/owner-1/pet-1/img-${i}.jpg`
),
})
).rejects.toThrow(TRPCError);
});
});
describe("posts.updateCaption", () => {
it.todo("PHOTO-03 — updateCaption forbidden for non-owner returns FORBIDDEN");
beforeEach(() => {
vi.clearAllMocks();
});
it("PHOTO-03 — updateCaption forbidden for non-owner returns FORBIDDEN", async () => {
// caller is owner-1, but the post belongs to owner-2's pet
const { caller, mockPrisma } = createCaller({ userId: "owner-1" });
// Post belongs to pet-2 which is owned by owner-2
mockPrisma.post.findUnique.mockResolvedValue({
id: "post-1",
petId: "pet-2",
pet: { id: "pet-2", ownerId: "owner-2" },
});
await expect(
caller.updateCaption({ postId: "post-1", caption: "Changed caption" })
).rejects.toThrow(TRPCError);
// Verify it was FORBIDDEN specifically
try {
await caller.updateCaption({ postId: "post-1", caption: "Changed" });
} catch (err) {
expect(err).toBeInstanceOf(TRPCError);
expect((err as TRPCError).code).toBe("FORBIDDEN");
}
});
});
describe("posts.delete", () => {
it.todo("PHOTO-04 — delete cascades PostImages and removes post from feed");
beforeEach(() => {
vi.clearAllMocks();
});
it("PHOTO-04 — delete cascades PostImages and removes post from feed", async () => {
const { caller, mockPrisma } = createCaller();
// Post belongs to pet-1 owned by owner-1
mockPrisma.post.findUnique.mockResolvedValue({
id: "post-1",
petId: "pet-1",
pet: { id: "pet-1", ownerId: "owner-1" },
});
mockPrisma.post.delete.mockResolvedValue({
id: "post-1",
petId: "pet-1",
});
await caller.delete({ postId: "post-1" });
// Prisma delete was called — PostImages cascade via schema onDelete:Cascade
expect(mockPrisma.post.delete).toHaveBeenCalledOnce();
expect(mockPrisma.post.delete).toHaveBeenCalledWith({
where: { id: "post-1" },
});
});
});