diff --git a/src/__tests__/posts.test.ts b/src/__tests__/posts.test.ts index c92158c..773eb0d 100644 --- a/src/__tests__/posts.test.ts +++ b/src/__tests__/posts.test.ts @@ -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(); + 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; +}) { + 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" }, + }); + }); });