test(02-02): add failing tests for follows router (RED)

- SOCL-01: follow creates row, self-follow rejected, ownership assertion, backfillOnFollow called
- SOCL-02: unfollow removes row, ownership assertion
- isFollowing: returns true/false based on DB state
- SOCL-03: listFollowers scoped by petId, listFollowing scoped by petId
This commit is contained in:
admin committed 2026-06-07 12:31:13 +02:00
1 parent 9219d3a907
commit 2e1cf45d7a
1 file changed
+264 -11
+264 -11
View File
@@ -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<typeof createMockPrisma>;
}) {
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");
});
});