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:
1 parent
9219d3a907
commit
2e1cf45d7a
1 file changed
+264
-11
+264
-11
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user