diff --git a/src/__tests__/follows.test.ts b/src/__tests__/follows.test.ts index 5c9214a..737129f 100644 --- a/src/__tests__/follows.test.ts +++ b/src/__tests__/follows.test.ts @@ -1,16 +1,19 @@ /** - * Follows Unit Tests — Phase 2 Plan 02-01 (scaffold) + * Follows Unit Tests — Phase 2 Plan 02-02 * * Covers: SOCL-01, SOCL-02, SOCL-03 * - * Test: follow creates row — follows.create inserts Follow row (SOCL-01) - * Test: unfollow removes row — follows.delete removes Follow row (SOCL-02) - * Test: listFollowers scoped — follows.listFollowers scoped by petId (SOCL-03) - * - * All tests are it.todo stubs until Wave 1 implementation. + * Tests: + * - follow creates row (SOCL-01) + * - self-follow is rejected (SOCL-01 guard) + * - follow triggers backfillOnFollow (SOCL-01 Redis backfill) + * - unfollow removes row (SOCL-02) + * - listFollowers scoped by petId (SOCL-03) + * - listFollowing scoped by petId (SOCL-03) */ -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", () => ({})); @@ -20,23 +23,273 @@ vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), })); +// Mock feed-helpers — prevent real Redis calls in tests +vi.mock("@/lib/feed-helpers", () => ({ + backfillOnFollow: vi.fn().mockResolvedValue(undefined), + fanOutPost: vi.fn().mockResolvedValue(undefined), + feedKey: vi.fn((petId: string) => `feed:${petId}`), + getFeedPage: vi.fn(), + trimFeed: vi.fn(), + PAGE_SIZE: 20, + FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000, +})); + +// Mock redis singleton (feed-helpers uses it; direct import safety) +vi.mock("@/lib/redis", () => ({ + redis: {}, +})); + // Mock prisma singleton vi.mock("@/lib/prisma", () => ({ prisma: {}, })); +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { backfillOnFollow } from "@/lib/feed-helpers"; + +// Inline caller factory to test the router directly with mocked context +async function createFollowsCaller(overrides?: { + userId?: string | null; + prisma?: ReturnType; +}) { + const { followsRouter } = await import("@/trpc/routers/follows"); + const { router } = await import("@/trpc/init"); + const { initTRPC, TRPCError: TRPCErr } = await import("@trpc/server"); + + const mockPrisma = overrides?.prisma ?? createMockPrisma(); + const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + + const t = initTRPC + .context<{ userId: string | null; prisma: typeof mockPrisma }>() + .create(); + + // Rebuild protectedProcedure locally for testing + const proc = t.procedure.use(({ ctx, next }) => { + if (!ctx.userId) throw new TRPCErr({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, userId: ctx.userId } }); + }); + + const testRouter = t.router({ + follows: followsRouter, + }); + + // Use the appRouter caller pattern with injected context + const caller = testRouter.createCaller({ + userId, + prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, + }); + + return { caller, mockPrisma }; +} + +// ─── follows.create ────────────────────────────────────────────────────────── + describe("follows.create", () => { - it.todo("SOCL-01 — follow creates a Follow row in the DB"); + it("SOCL-01 — follow creates a Follow row in the DB", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" }; + mockPrisma.pet.findUnique.mockResolvedValue(mockPet); + mockPrisma.follow.create.mockResolvedValue({ + followerPetId: "pet-1", + followeePetId: "pet-2", + createdAt: new Date(), + }); + + const result = await caller.follows.create({ + followerPetId: "pet-1", + followeePetId: "pet-2", + }); + + expect(mockPrisma.follow.create).toHaveBeenCalledWith({ + data: { followerPetId: "pet-1", followeePetId: "pet-2" }, + }); + expect(result).toMatchObject({ followerPetId: "pet-1", followeePetId: "pet-2" }); + }); + + it("SOCL-01 — self-follow is rejected with BAD_REQUEST", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" }; + mockPrisma.pet.findUnique.mockResolvedValue(mockPet); + + await expect( + caller.follows.create({ followerPetId: "pet-1", followeePetId: "pet-1" }) + ).rejects.toThrow(TRPCError); + + const err = await caller.follows + .create({ followerPetId: "pet-1", followeePetId: "pet-1" }) + .catch((e) => e); + expect(err.code).toBe("BAD_REQUEST"); + }); + + it("SOCL-01 — ownership assertion: cannot follow as a pet you don't own", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + // Pet belongs to a different owner + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-99", + ownerId: "other-owner", + name: "Other", + }); + + await expect( + caller.follows.create({ followerPetId: "pet-99", followeePetId: "pet-2" }) + ).rejects.toThrow(TRPCError); + }); + + it("SOCL-01 — backfillOnFollow is called after creating a follow", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + const mockBackfill = vi.mocked(backfillOnFollow); + mockBackfill.mockClear(); + + const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" }; + mockPrisma.pet.findUnique.mockResolvedValue(mockPet); + mockPrisma.follow.create.mockResolvedValue({ + followerPetId: "pet-1", + followeePetId: "pet-2", + createdAt: new Date(), + }); + + await caller.follows.create({ followerPetId: "pet-1", followeePetId: "pet-2" }); + + expect(mockBackfill).toHaveBeenCalledOnce(); + expect(mockBackfill).toHaveBeenCalledWith( + expect.anything(), // redis instance + "pet-1", + "pet-2", + expect.anything() // prisma instance + ); + }); }); +// ─── follows.delete ─────────────────────────────────────────────────────────── + describe("follows.delete", () => { - it.todo("SOCL-02 — unfollow removes the Follow row from the DB"); + it("SOCL-02 — unfollow removes the Follow row from the DB", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" }; + mockPrisma.pet.findUnique.mockResolvedValue(mockPet); + mockPrisma.follow.delete.mockResolvedValue({ + followerPetId: "pet-1", + followeePetId: "pet-2", + createdAt: new Date(), + }); + + await caller.follows.delete({ followerPetId: "pet-1", followeePetId: "pet-2" }); + + expect(mockPrisma.follow.delete).toHaveBeenCalledWith({ + where: { + followerPetId_followeePetId: { + followerPetId: "pet-1", + followeePetId: "pet-2", + }, + }, + }); + }); + + it("SOCL-02 — ownership assertion: cannot unfollow as a pet you don't own", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-99", + ownerId: "other-owner", + name: "Other", + }); + + await expect( + caller.follows.delete({ followerPetId: "pet-99", followeePetId: "pet-2" }) + ).rejects.toThrow(TRPCError); + }); }); +// ─── follows.isFollowing ────────────────────────────────────────────────────── + +describe("follows.isFollowing", () => { + it("returns true when a follow relationship exists", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + mockPrisma.follow.findUnique.mockResolvedValue({ + followerPetId: "pet-1", + followeePetId: "pet-2", + createdAt: new Date(), + }); + + const result = await caller.follows.isFollowing({ + followerPetId: "pet-1", + followeePetId: "pet-2", + }); + + expect(result).toBe(true); + }); + + it("returns false when no follow relationship exists", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + mockPrisma.follow.findUnique.mockResolvedValue(null); + + const result = await caller.follows.isFollowing({ + followerPetId: "pet-1", + followeePetId: "pet-2", + }); + + expect(result).toBe(false); + }); +}); + +// ─── follows.listFollowers ──────────────────────────────────────────────────── + describe("follows.listFollowers", () => { - it.todo("SOCL-03 — listFollowers returns only followers for the given petId"); + it("SOCL-03 — listFollowers returns only followers for the given petId", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + const mockFollowers = [ + { + followerPetId: "pet-2", + followeePetId: "pet-1", + createdAt: new Date(), + follower: { id: "pet-2", name: "Max", avatarKey: null, species: { name: "Dog" } }, + }, + ]; + mockPrisma.follow.findMany.mockResolvedValue(mockFollowers); + + const result = await caller.follows.listFollowers({ petId: "pet-1" }); + + expect(mockPrisma.follow.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { followeePetId: "pet-1" }, + }) + ); + expect(result).toHaveLength(1); + expect(result[0].follower.name).toBe("Max"); + }); }); +// ─── follows.listFollowing ──────────────────────────────────────────────────── + describe("follows.listFollowing", () => { - it.todo("SOCL-03 — listFollowing returns only pets followed by the given petId"); + it("SOCL-03 — listFollowing returns only pets followed by the given petId", async () => { + const { caller, mockPrisma } = await createFollowsCaller(); + + const mockFollowing = [ + { + followerPetId: "pet-1", + followeePetId: "pet-3", + createdAt: new Date(), + followee: { id: "pet-3", name: "Luna", avatarKey: null, species: { name: "Cat" } }, + }, + ]; + mockPrisma.follow.findMany.mockResolvedValue(mockFollowing); + + const result = await caller.follows.listFollowing({ petId: "pet-1" }); + + expect(mockPrisma.follow.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { followerPetId: "pet-1" }, + }) + ); + expect(result).toHaveLength(1); + expect(result[0].followee.name).toBe("Luna"); + }); });