test(02-05): add stories tests RED — STORY-01-03

This commit is contained in:
admin committed 2026-06-13 10:08:16 +02:00
1 parent fa9b33ad7e
commit 99bff88545
2 files changed
+278 -35

No files matched your search

+144 -14
View File
@@ -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<typeof createMockPrisma>;
}) {
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" });
});
});
+134 -21
View File
@@ -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" },
});
}),
});