test(02-03): add failing tests for feed.getFeed FEED-01

- Empty feed: returns [] and null nextCursor for pet with no follows
- Cursor pagination: nextCursor passed through from getFeedPage
- Block filter: blocked pets excluded from hydration
- Ownership: throws FORBIDDEN when petId is not owned by requester
This commit is contained in:
2026-06-07 12:31:03 +02:00
parent fb76e7cd7b
commit 9219d3a907
+173 -6
View File
@@ -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<typeof import("@/lib/feed-helpers")>();
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<typeof createMockPrisma>;
}) {
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);
});
});