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:
+173
-6
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user