test(02-06): add milestones tests RED → implement milestonesRouter GREEN — MILE-01
This commit is contained in:
1 parent
9a901e3485
commit
554ca1deae
2 files changed
+205
-24
No files matched your search
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user