diff --git a/src/__tests__/stories.test.ts b/src/__tests__/stories.test.ts index 05aa141..f3288e7 100644 --- a/src/__tests__/stories.test.ts +++ b/src/__tests__/stories.test.ts @@ -1,38 +1,168 @@ /** - * Stories Unit Tests — Phase 2 Plan 02-01 (scaffold) + * Stories Unit Tests — Phase 2 Plan 02-05 * * Covers: STORY-01, STORY-02, STORY-03 - * - * Test: expiresAt 24h — story.create sets expiresAt = now + 24h (STORY-01) - * Test: hasActiveStory — filters by expiresAt > now (STORY-02) - * Test: recordView dedup — upsert deduplicates by (storyId, viewerPetId) (STORY-03) - * - * 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/supabase-storage", () => ({ + createSignedUploadUrl: vi.fn().mockResolvedValue({ + signedUrl: "https://storage.example.com/signed", + path: "stories/owner-1/pet-1/uuid.jpg", + }), + ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], + MAX_FILE_SIZE: 10 * 1024 * 1024, +})); + vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), })); -// Mock prisma singleton vi.mock("@/lib/prisma", () => ({ prisma: {}, })); +import { storiesRouter } from "@/trpc/routers/stories"; +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +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: storiesRouter.createCaller(ctx as any), mockPrisma }; +} + describe("stories.create", () => { - it.todo("STORY-01 — expiresAt set to approximately now + 24h on story creation"); + beforeEach(() => vi.clearAllMocks()); + + it("STORY-01 — expiresAt set to approximately now + 24h on story creation", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + + const mockStory = { + id: "story-1", + petId: "pet-1", + storageKey: "stories/owner-1/pet-1/uuid.jpg", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + createdAt: new Date(), + }; + mockPrisma.story.create.mockResolvedValue(mockStory); + + const before = Date.now(); + const result = await caller.create({ + petId: "pet-1", + storageKey: "stories/owner-1/pet-1/uuid.jpg", + }); + const after = Date.now(); + + expect(mockPrisma.story.create).toHaveBeenCalledOnce(); + + const createCall = mockPrisma.story.create.mock.calls[0][0]; + const expiresAt: Date = createCall.data.expiresAt; + + const expectedMin = before + 24 * 60 * 60 * 1000 - 1000; + const expectedMax = after + 24 * 60 * 60 * 1000 + 1000; + expect(expiresAt.getTime()).toBeGreaterThan(expectedMin); + expect(expiresAt.getTime()).toBeLessThan(expectedMax); + + expect(result.id).toBe("story-1"); + }); + + it("STORY-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", storageKey: "stories/owner-1/pet-1/uuid.jpg" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); describe("stories.hasActiveStory", () => { - it.todo("STORY-02 — hasActiveStory filters stories where expiresAt > now"); + beforeEach(() => vi.clearAllMocks()); + + it("STORY-02 — hasActiveStory returns true when a non-expired story exists", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.story.count.mockResolvedValue(1); + + const result = await caller.hasActiveStory({ petId: "pet-1" }); + + expect(result).toBe(true); + expect(mockPrisma.story.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + petId: "pet-1", + expiresAt: expect.objectContaining({ gt: expect.any(Date) }), + }), + }) + ); + }); + + it("STORY-02 — hasActiveStory returns false when no active stories exist", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.story.count.mockResolvedValue(0); + + const result = await caller.hasActiveStory({ petId: "pet-1" }); + + expect(result).toBe(false); + }); }); describe("stories.recordView", () => { - it.todo("STORY-03 — recordView deduplicates: second view for same (storyId, viewerPetId) is an upsert"); + beforeEach(() => vi.clearAllMocks()); + + it("STORY-03 — recordView upserts by composite (storyId, viewerPetId) for dedup", async () => { + const { caller, mockPrisma } = createCaller(); + + const mockStory = { + id: "story-1", + petId: "pet-1", + expiresAt: new Date(Date.now() + 1000), + }; + mockPrisma.story.findUnique.mockResolvedValue(mockStory); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-1" }); + + mockPrisma.storyView.upsert.mockResolvedValue({ + storyId: "story-1", + viewerPetId: "pet-2", + viewedAt: new Date(), + }); + + await caller.recordView({ storyId: "story-1", viewerPetId: "pet-2" }); + + expect(mockPrisma.storyView.upsert).toHaveBeenCalledOnce(); + expect(mockPrisma.storyView.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { storyId_viewerPetId: { storyId: "story-1", viewerPetId: "pet-2" } }, + update: expect.objectContaining({ viewedAt: expect.any(Date) }), + create: expect.objectContaining({ storyId: "story-1", viewerPetId: "pet-2" }), + }) + ); + }); + + it("STORY-03 — recordView throws NOT_FOUND if story is expired or missing", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.story.findUnique.mockResolvedValue(null); + + await expect( + caller.recordView({ storyId: "expired-story", viewerPetId: "pet-2" }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); }); diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts index f99e7a7..49574b6 100644 --- a/src/trpc/routers/stories.ts +++ b/src/trpc/routers/stories.ts @@ -2,20 +2,63 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "@/trpc/init"; +import { + createSignedUploadUrl, + ALLOWED_CONTENT_TYPES, + MAX_FILE_SIZE, +} from "@/lib/supabase-storage"; +import { randomUUID } from "crypto"; +import type { PrismaClient } from "@prisma/client"; /** * Stories tRPC router — 24-hour photo stories with lazy-delete expiry (D-07). * - * Procedure keys: - * create — create a story with expiresAt = now + 24h - * listActive — list non-expired stories for a pet (expiresAt > now) - * recordView — upsert a story view (dedup by storyId + viewerPetId per D-08) - * getViewers — list pets that viewed a story (STORY-03) - * hasActiveStory — returns boolean; used for story ring display (STORY-02) - * - * Implementation: downstream Wave 1 plans. + * Security (T-02-40 to T-02-44): + * - create asserts pet ownership before write (T-02-40) + * - storageKey validated with .startsWith('stories/') — path-traversal prevention (T-02-41) + * - recordView upserts by composite key — one view per viewer (T-02-42) + * - getViewers asserts story ownership before exposing viewer list (T-02-43) + * - All queries filter expiresAt > now — expired stories never returned (T-02-44) */ + +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 storiesRouter = router({ + /** + * Generate a presigned PUT URL for direct upload to Supabase Storage. + * Key scoped to stories/{ownerId}/{petId}/{uuid}.ext (T-02-41). + */ + getPresignedUrl: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + contentType: z.enum(ALLOWED_CONTENT_TYPES), + fileSize: z.number().max(MAX_FILE_SIZE, "Photo must be under 10 MB"), + }) + ) + .mutation(async ({ ctx, input }) => { + await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + + const ext = input.contentType.split("/")[1].replace("jpeg", "jpg"); + const key = `stories/${ctx.userId}/${input.petId}/${randomUUID()}.${ext}`; + const { signedUrl } = await createSignedUploadUrl(key); + return { presignedUrl: signedUrl, key }; + }), + + /** + * Create a story with expiresAt = now + 24h UTC (STORY-01). + * Photo-only in Phase 2 — storageKey must be a stories/ path. + */ create: protectedProcedure .input( z.object({ @@ -23,16 +66,50 @@ export const storiesRouter = router({ storageKey: z.string().startsWith("stories/"), }) ) - .mutation(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }) => { + await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + + // Pitfall 5: use explicit UTC arithmetic — avoids timezone issues + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + return ctx.prisma.story.create({ + data: { + petId: input.petId, + storageKey: input.storageKey, + expiresAt, + }, + }); }), + /** + * List active (non-expired) stories for a pet (D-07 lazy-delete pattern). + */ listActive: protectedProcedure .input(z.object({ petId: z.string().min(1) })) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }) => { + return ctx.prisma.story.findMany({ + where: { petId: input.petId, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "asc" }, + }); }), + /** + * Returns true if the pet has at least one non-expired story (STORY-02). + * Used by PawRing to decide whether to show the paw outline. + */ + hasActiveStory: protectedProcedure + .input(z.object({ petId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const count = await ctx.prisma.story.count({ + where: { petId: input.petId, expiresAt: { gt: new Date() } }, + }); + return count > 0; + }), + + /** + * Upsert a story view — deduplicates by composite (storyId, viewerPetId) (STORY-03). + * Asserts the viewer pet belongs to ctx.userId and the story is not expired. + */ recordView: protectedProcedure .input( z.object({ @@ -40,19 +117,55 @@ export const storiesRouter = router({ viewerPetId: z.string().min(1), }) ) - .mutation(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }) => { + const story = await ctx.prisma.story.findUnique({ + where: { id: input.storyId }, + }); + if (!story || story.expiresAt <= new Date()) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + await assertPetOwnership(ctx.prisma, input.viewerPetId, ctx.userId); + + const now = new Date(); + return ctx.prisma.storyView.upsert({ + where: { + storyId_viewerPetId: { + storyId: input.storyId, + viewerPetId: input.viewerPetId, + }, + }, + update: { viewedAt: now }, + create: { + storyId: input.storyId, + viewerPetId: input.viewerPetId, + viewedAt: now, + }, + }); }), + /** + * List pets that viewed a story — only the story owner may call this (STORY-03). + * Returns viewer pet list ordered by most recent view (D-08 30-day retention via schema). + */ getViewers: protectedProcedure .input(z.object({ storyId: z.string().min(1) })) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); - }), + .query(async ({ ctx, input }) => { + const story = await ctx.prisma.story.findUnique({ + where: { id: input.storyId }, + select: { id: true, pet: { select: { ownerId: true } } }, + }); - hasActiveStory: protectedProcedure - .input(z.object({ petId: z.string().min(1) })) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + if (!story || story.pet.ownerId !== ctx.userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + return ctx.prisma.storyView.findMany({ + where: { storyId: input.storyId }, + include: { + viewerPet: { select: { id: true, name: true, avatarKey: true } }, + }, + orderBy: { viewedAt: "desc" }, + }); }), });