test(02-05): add stories tests RED — STORY-01-03
This commit is contained in:
1 parent
fa9b33ad7e
commit
99bff88545
2 files changed
+278
-35
No files matched your search
+144
-14
@@ -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
@@ -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" },
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user