diff --git a/src/__tests__/milestones.test.ts b/src/__tests__/milestones.test.ts index 2ca6fa6..abf2a82 100644 --- a/src/__tests__/milestones.test.ts +++ b/src/__tests__/milestones.test.ts @@ -1,30 +1,146 @@ /** - * Milestones Unit Tests — Phase 2 Plan 02-01 (scaffold) + * Milestones Unit Tests — Phase 2 Plan 02-06 * * Covers: MILE-01 - * - * Test: invalid type — milestones.create rejects invalid milestone type (MILE-01) - * - * 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", () => ({})); -// Mock Clerk server auth +vi.mock("@/lib/redis", () => ({ + redis: { + pipeline: vi.fn(() => ({ zadd: vi.fn(), exec: vi.fn().mockResolvedValue([]) })), + }, +})); + +vi.mock("@/lib/feed-helpers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fanOutPost: vi.fn().mockResolvedValue(undefined) }; +}); + vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), })); -// Mock prisma singleton -vi.mock("@/lib/prisma", () => ({ - prisma: {}, -})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +import { milestonesRouter } from "@/trpc/routers/milestones"; +import { fanOutPost } from "@/lib/feed-helpers"; +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +const mockFanOutPost = vi.mocked(fanOutPost); + +function createCaller(overrides?: { + userId?: string; + prisma?: ReturnType; +}) { + const mockPrisma = overrides?.prisma ?? createMockPrisma(); + const ctx = { + userId: overrides?.userId ?? "owner-1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: mockPrisma as any, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { caller: milestonesRouter.createCaller(ctx as any), mockPrisma }; +} describe("milestones.create", () => { - it.todo("MILE-01 — invalid milestone type (not in MilestoneType enum) returns BAD_REQUEST"); + beforeEach(() => vi.clearAllMocks()); - it.todo("MILE-01 — valid milestone type creates Post + Milestone row"); + it("MILE-01 — valid milestone type creates Post type MILESTONE + Milestone row + fanOut", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + + const mockPost = { + id: "post-1", + petId: "pet-1", + type: "MILESTONE", + caption: "Happy birthday!", + createdAt: new Date("2026-06-13T00:00:00Z"), + updatedAt: new Date(), + images: [], + milestone: { id: "ms-1", postId: "post-1", milestoneType: "BIRTHDAY", notes: null }, + pet: { id: "pet-1", name: "Buddy", avatarKey: null }, + }; + mockPrisma.post.create.mockResolvedValue(mockPost); + + const result = await caller.create({ + petId: "pet-1", + milestoneType: "BIRTHDAY", + caption: "Happy birthday!", + }); + + expect(mockPrisma.post.create).toHaveBeenCalledOnce(); + expect(mockPrisma.post.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + petId: "pet-1", + type: "MILESTONE", + milestone: expect.objectContaining({ + create: expect.objectContaining({ milestoneType: "BIRTHDAY" }), + }), + }), + }) + ); + expect(mockFanOutPost).toHaveBeenCalledOnce(); + expect(result.id).toBe("post-1"); + expect(result.type).toBe("MILESTONE"); + }); + + it("MILE-01 — invalid milestone type (not in enum) is rejected by Zod", async () => { + const { caller } = createCaller(); + + await expect( + caller.create({ + petId: "pet-1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + milestoneType: "INVALID_TYPE" as any, + }) + ).rejects.toThrow(TRPCError); + }); + + it("MILE-01 — LITTER milestone accepts free-text notes", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + mockPrisma.post.create.mockResolvedValue({ + id: "post-2", + petId: "pet-1", + type: "MILESTONE", + caption: null, + createdAt: new Date(), + updatedAt: new Date(), + images: [], + milestone: { milestoneType: "LITTER", notes: "5 healthy kittens" }, + pet: { id: "pet-1", name: "Mittens", avatarKey: null }, + }); + + await caller.create({ + petId: "pet-1", + milestoneType: "LITTER", + notes: "5 healthy kittens", + }); + + expect(mockPrisma.post.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + milestone: expect.objectContaining({ + create: expect.objectContaining({ notes: "5 healthy kittens" }), + }), + }), + }) + ); + }); + + it("MILE-01 — create throws FORBIDDEN if pet does not belong to userId", async () => { + const { caller, mockPrisma } = createCaller({ userId: "owner-1" }); + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-2" }); + + await expect( + caller.create({ petId: "pet-2", milestoneType: "BIRTHDAY" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); diff --git a/src/trpc/routers/milestones.ts b/src/trpc/routers/milestones.ts index 44c87db..42e9710 100644 --- a/src/trpc/routers/milestones.ts +++ b/src/trpc/routers/milestones.ts @@ -3,17 +3,37 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { MilestoneType } from "@prisma/client"; import { router, protectedProcedure } from "@/trpc/init"; +import { redis } from "@/lib/redis"; +import { fanOutPost } from "@/lib/feed-helpers"; +import type { PrismaClient } from "@prisma/client"; /** - * Milestones tRPC router — structured milestone posts (D-09). + * Milestones tRPC router — structured milestone posts (MILE-01, MILE-02). * - * Procedure keys: - * create — create a milestone post with a validated MilestoneType enum value - * listByPet — list milestone posts for a pet (MILE-02) - * - * Implementation: downstream Wave 1 plans. + * Security (T-02-50 to T-02-53): + * - create asserts pet ownership before any write (T-02-50) + * - milestoneType constrained to z.nativeEnum(MilestoneType) — rejects forged types (T-02-51) + * - optional imageKey validated with .startsWith('posts/') (T-02-52) + * - follower-threshold types excluded from MilestoneForm picker (T-02-53, D-10) */ + +async function assertPetOwnership( + prisma: PrismaClient, + petId: string, + userId: string +) { + const pet = await prisma.pet.findUnique({ where: { id: petId } }); + if (!pet || pet.ownerId !== userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return pet; +} + export const milestonesRouter = router({ + /** + * Create a milestone post: Post(type=MILESTONE) + nested Milestone + optional PostImage. + * Fans out to all follower Redis feed inboxes (MILE-01). + */ create: protectedProcedure .input( z.object({ @@ -24,10 +44,43 @@ export const milestonesRouter = router({ imageKey: z.string().startsWith("posts/").optional(), }) ) - .mutation(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }) => { + await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + + const post = await ctx.prisma.post.create({ + data: { + petId: input.petId, + type: "MILESTONE", + caption: input.caption ?? null, + milestone: { + create: { + milestoneType: input.milestoneType, + notes: input.notes ?? null, + }, + }, + ...(input.imageKey + ? { + images: { + create: [{ storageKey: input.imageKey, position: 0 }], + }, + } + : {}), + }, + include: { + images: { orderBy: { position: "asc" } }, + milestone: true, + pet: { select: { id: true, name: true, avatarKey: true } }, + }, + }); + + await fanOutPost(redis, post.id, input.petId, post.createdAt, ctx.prisma); + + return post; }), + /** + * List milestone posts for a pet — used by the profile Milestones tab (MILE-02). + */ listByPet: protectedProcedure .input( z.object({ @@ -36,7 +89,19 @@ export const milestonesRouter = router({ limit: z.number().min(1).max(50).default(20), }) ) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }) => { + return ctx.prisma.post.findMany({ + where: { petId: input.petId, type: "MILESTONE" }, + include: { + milestone: true, + images: { orderBy: { position: "asc" } }, + pet: { select: { id: true, name: true, avatarKey: true } }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + ...(input.cursor + ? { skip: 1, cursor: { id: input.cursor } } + : {}), + }); }), });