diff --git a/src/__tests__/feed.test.ts b/src/__tests__/feed.test.ts index 5aa2b91..06f8333 100644 --- a/src/__tests__/feed.test.ts +++ b/src/__tests__/feed.test.ts @@ -1,16 +1,17 @@ /** - * Feed Unit Tests — Phase 2 Plan 02-01 (scaffold) + * Feed Unit Tests — Phase 2 Plan 02-03 * * Covers: FEED-01 * * Test: empty feed — feed.getFeed returns empty for pet with no follows * Test: cursor pagination — feed.getFeed returns nextCursor when hasNextPage + * Test: blocked pets excluded — blocked pets not included in hydrated results * * Redis is mocked via vi.mock('@/lib/redis') — no real Upstash calls in tests. - * All tests are it.todo stubs until Wave 1 implementation. */ -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", () => ({})); @@ -38,10 +39,176 @@ vi.mock("@/lib/prisma", () => ({ prisma: {}, })); +// We need to import getFeedPage after mocks +// getFeed-helpers mock — we control what it returns in each test +vi.mock("@/lib/feed-helpers", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getFeedPage: vi.fn(), + }; +}); + +import { feedRouter } from "@/trpc/routers/feed"; +import { getFeedPage } from "@/lib/feed-helpers"; +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +const mockGetFeedPage = vi.mocked(getFeedPage); + +// 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", + prisma: mockPrisma as unknown as Parameters< + typeof feedRouter.createCaller + >[0]["prisma"], + }; + return { caller: feedRouter.createCaller(ctx), mockPrisma }; +} + describe("feed.getFeed", () => { - it.todo("FEED-01 — empty feed returns empty posts array and null nextCursor for pet with no follows"); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("FEED-01 — empty feed returns empty posts array and null nextCursor for pet with no follows", async () => { + const { caller, mockPrisma } = createCaller(); + + // Pet belongs to owner + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-1", + }); + + // Redis returns no post IDs (empty feed) + mockGetFeedPage.mockResolvedValue({ postIds: [], nextCursor: null }); + + // No blocks + mockPrisma.block.findMany.mockResolvedValue([]); + + // No posts to hydrate + mockPrisma.post.findMany.mockResolvedValue([]); + + const result = await caller.getFeed({ + petId: "pet-1", + cursor: null, + limit: 20, + }); + + expect(result.posts).toEqual([]); + expect(result.nextCursor).toBeNull(); + }); + + it("FEED-01 — cursor pagination returns nextCursor when more results available", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-1", + }); + + const postIds = ["post-1", "post-2", "post-3"]; + const nextCursorValue = 1700000000000; + + // Redis returns 3 post IDs with a next cursor + mockGetFeedPage.mockResolvedValue({ + postIds, + nextCursor: nextCursorValue, + }); + + // No blocks + mockPrisma.block.findMany.mockResolvedValue([]); + + // Hydrated posts — ordered to match Redis order + const mockPosts = postIds.map((id, i) => ({ + id, + petId: "pet-2", + caption: `Caption ${i}`, + createdAt: new Date(nextCursorValue + i * 1000), + pet: { id: "pet-2", name: "Buddy", avatarKey: null }, + images: [], + milestone: null, + })); + mockPrisma.post.findMany.mockResolvedValue(mockPosts); + + const result = await caller.getFeed({ + petId: "pet-1", + cursor: null, + limit: 20, + }); + + // nextCursor passed through from getFeedPage + expect(result.nextCursor).toBe(nextCursorValue); + // posts returned + expect(result.posts).toHaveLength(3); + // post order matches Redis postIds order + expect(result.posts.map((p) => p.id)).toEqual(postIds); + }); + + it("FEED-01 — blocked pets are excluded from feed hydration", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-1", + }); + + const postIds = ["post-1", "post-2"]; + mockGetFeedPage.mockResolvedValue({ postIds, nextCursor: null }); + + // pet-blocked is blocked + mockPrisma.block.findMany.mockResolvedValue([ + { blockedPetId: "pet-blocked" }, + ]); + + // Prisma filters out blocked pet's post — only 1 post returned + const mockPosts = [ + { + id: "post-1", + petId: "pet-2", + caption: "Hello", + createdAt: new Date(), + pet: { id: "pet-2", name: "Buddy", avatarKey: null }, + images: [], + milestone: null, + }, + ]; + mockPrisma.post.findMany.mockResolvedValue(mockPosts); + + const result = await caller.getFeed({ + petId: "pet-1", + cursor: null, + limit: 20, + }); + + // Only 1 post returned (blocked pet's post filtered at Prisma layer) + expect(result.posts).toHaveLength(1); + expect(result.posts[0].id).toBe("post-1"); + + // Verify block filter was queried with correct petId + expect(mockPrisma.block.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { blockerPetId: "pet-1" }, + }) + ); + }); + + it("FEED-01 — throws FORBIDDEN when petId does not belong to requesting user", async () => { + const { caller, mockPrisma } = createCaller({ userId: "owner-1" }); - it.todo("FEED-01 — cursor pagination returns nextCursor when more results available"); + // Pet belongs to a different owner + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-DIFFERENT", + }); - it.todo("FEED-01 — blocked pets are excluded from feed hydration"); + await expect( + caller.getFeed({ petId: "pet-1", cursor: null, limit: 20 }) + ).rejects.toThrow(TRPCError); + }); });