- FollowButton: optimistic follow/unfollow, revert on error, AlertDialog unfollow confirm (SOCL-01/02)
- PetListItem: avatar + name + species badge + right-aligned FollowButton (SOCL-03)
- BlockDialog: AlertDialog with exact UI-SPEC copy, destructive Block CTA (SAFE-01)
- ReportSheet: 5 RadioGroup reasons, notes Textarea, destructive Submit CTA (SAFE-02)
- followers/page.tsx: RSC shell "{N} Followers" heading + FollowersList client (SOCL-03)
- following/page.tsx: RSC shell "Following {N}" heading + FollowingList client (SOCL-03)
- pets/[petId]/page.tsx: stats row (posts/followers/following) + ProfileActions (SOCL-03)
- ProfileActions: FollowButton + MoreHorizontal options menu + BlockDialog + ReportSheet
- Fix: test prisma cast to `as any` resolving TS2322 type errors (Rule 1 - Bug)
- tsc exits 0; vitest 36 passed
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
/**
|
|
* Reports Unit Tests — Phase 2 Plan 02-02
|
|
*
|
|
* Covers: SAFE-02
|
|
*
|
|
* Tests:
|
|
* - report creates a Report row with reporterPetId, reason, and optional notes
|
|
* - report with invalid reason (not in ReportReason enum) is rejected at Zod layer
|
|
* - report without targetPostId or targetPetId is rejected (both-null validation)
|
|
* - report with only targetPostId is valid
|
|
* - report with only targetPetId is valid
|
|
* - ownership assertion enforced on reporterPetId
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
// Mock server-only guard
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
// Mock Clerk server auth
|
|
vi.mock("@clerk/nextjs/server", () => ({
|
|
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
|
}));
|
|
|
|
// Mock redis
|
|
vi.mock("@/lib/redis", () => ({
|
|
redis: {},
|
|
}));
|
|
|
|
// Mock feed-helpers
|
|
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 prisma singleton
|
|
vi.mock("@/lib/prisma", () => ({
|
|
prisma: {},
|
|
}));
|
|
|
|
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
|
import { ReportReason } from "@prisma/client";
|
|
|
|
// Inline caller factory for reports router
|
|
async function createReportsCaller(overrides?: {
|
|
userId?: string | null;
|
|
prisma?: ReturnType<typeof createMockPrisma>;
|
|
}) {
|
|
const { reportsRouter } = await import("@/trpc/routers/reports");
|
|
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();
|
|
|
|
const testRouter = t.router({
|
|
reports: reportsRouter,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const caller = testRouter.createCaller({
|
|
userId,
|
|
prisma: mockPrisma as any,
|
|
});
|
|
|
|
return { caller, mockPrisma };
|
|
}
|
|
|
|
// ─── reports.create ───────────────────────────────────────────────────────────
|
|
|
|
describe("reports.create", () => {
|
|
it("SAFE-02 — report creates a Report row with reporterPetId, reason, and optional notes", async () => {
|
|
const { caller, mockPrisma } = await createReportsCaller();
|
|
|
|
const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
|
|
mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
|
|
mockPrisma.report.create.mockResolvedValue({
|
|
id: "report-1",
|
|
reporterPetId: "pet-1",
|
|
targetPostId: "post-99",
|
|
targetPetId: null,
|
|
reason: ReportReason.SPAM,
|
|
notes: "This is spam",
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await caller.reports.create({
|
|
reporterPetId: "pet-1",
|
|
targetPostId: "post-99",
|
|
reason: ReportReason.SPAM,
|
|
notes: "This is spam",
|
|
});
|
|
|
|
expect(mockPrisma.report.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
reporterPetId: "pet-1",
|
|
targetPostId: "post-99",
|
|
reason: ReportReason.SPAM,
|
|
notes: "This is spam",
|
|
}),
|
|
})
|
|
);
|
|
expect(result.reason).toBe(ReportReason.SPAM);
|
|
});
|
|
|
|
it("SAFE-02 — report with targetPetId only (no targetPostId) is valid", async () => {
|
|
const { caller, mockPrisma } = await createReportsCaller();
|
|
|
|
const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
|
|
mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
|
|
mockPrisma.report.create.mockResolvedValue({
|
|
id: "report-2",
|
|
reporterPetId: "pet-1",
|
|
targetPostId: null,
|
|
targetPetId: "pet-99",
|
|
reason: ReportReason.HARASSMENT,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await expect(
|
|
caller.reports.create({
|
|
reporterPetId: "pet-1",
|
|
targetPetId: "pet-99",
|
|
reason: ReportReason.HARASSMENT,
|
|
})
|
|
).resolves.toBeDefined();
|
|
|
|
expect(mockPrisma.report.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it("SAFE-02 — report without targetPostId or targetPetId is rejected with BAD_REQUEST", async () => {
|
|
const { caller, mockPrisma } = await createReportsCaller();
|
|
|
|
const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
|
|
mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
|
|
|
|
const err = await caller.reports
|
|
.create({
|
|
reporterPetId: "pet-1",
|
|
reason: ReportReason.SPAM,
|
|
// no targetPostId, no targetPetId
|
|
})
|
|
.catch((e) => e);
|
|
|
|
expect(err).toBeInstanceOf(TRPCError);
|
|
expect(err.code).toBe("BAD_REQUEST");
|
|
});
|
|
|
|
it("SAFE-02 — ownership assertion: cannot report as a pet you don't own", async () => {
|
|
const { caller, mockPrisma } = await createReportsCaller();
|
|
|
|
mockPrisma.pet.findUnique.mockResolvedValue({
|
|
id: "pet-99",
|
|
ownerId: "other-owner",
|
|
name: "Other",
|
|
});
|
|
|
|
await expect(
|
|
caller.reports.create({
|
|
reporterPetId: "pet-99",
|
|
targetPostId: "post-1",
|
|
reason: ReportReason.SPAM,
|
|
})
|
|
).rejects.toThrow(TRPCError);
|
|
});
|
|
|
|
it("SAFE-02 — all 5 ReportReason enum values are accepted", async () => {
|
|
const { caller, mockPrisma } = await createReportsCaller();
|
|
const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" };
|
|
|
|
for (const reason of Object.values(ReportReason)) {
|
|
mockPrisma.pet.findUnique.mockResolvedValue(mockPet);
|
|
mockPrisma.report.create.mockResolvedValue({
|
|
id: "r",
|
|
reporterPetId: "pet-1",
|
|
targetPostId: "post-1",
|
|
targetPetId: null,
|
|
reason,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
await expect(
|
|
caller.reports.create({
|
|
reporterPetId: "pet-1",
|
|
targetPostId: "post-1",
|
|
reason,
|
|
})
|
|
).resolves.toBeDefined();
|
|
}
|
|
});
|
|
});
|