test(02-06): add milestones tests RED → implement milestonesRouter GREEN — MILE-01

This commit is contained in:
admin committed 2026-06-13 10:21:40 +02:00
1 parent 9a901e3485
commit 554ca1deae
2 files changed
+205 -24

No files matched your search

+130 -14
View File
@@ -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<typeof import("@/lib/feed-helpers")>();
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<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: 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" });
});
});
+75 -10
View File
@@ -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 } }
: {}),
});
}),
});