test(01-03): implement pet-profile unit tests (PET-01 through PET-05)

- PET-01: create stores pet with ownerId, validates name<=30, upserts owner FK
- PET-02: update FORBIDDEN for non-owner, succeeds for owner
- PET-03: delete by owner succeeds ({ success: true }); FORBIDDEN for non-owner
- PET-05: adoptionStory persists to DB; validates max 500 chars
- Ownership assertion: FORBIDDEN tested for both update and delete (T-3-01)
- All 11 tests passing; no real DB calls (mocked via createMockPrisma)
This commit is contained in:
admin committed 2026-06-06 10:18:52 +02:00
1 parent 95dfec7bdf
commit f996459133
1 file changed
+263 -42
+263 -42
View File
@@ -1,70 +1,291 @@
/**
* Wave 0 Test Scaffold — Pet Profile
* Pet Profile Unit Tests — Plan 03
*
* Covers: PET-01 (create with valid input)
* PET-02 (update rejected for non-owner -> FORBIDDEN)
* PET-03 (delete by owner)
* PET-03 (delete by owner succeeds)
* PET-04 (delete rejected for non-owner -> FORBIDDEN)
* PET-05 (adoption story persists)
* Ownership assertion
* Ownership assertions for update and delete
*
* These tests are implemented against the tRPC pets router with a mocked
* PrismaClient. Full implementation arrives in Plan 03.
* All Prisma calls are mocked — no real DB connections.
* Security (T-3-01): FORBIDDEN assertions are tested for both update and delete.
*/
import { describe, it } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { petsRouter } from "@/trpc/routers/pets";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
// Mock Clerk server auth — pets router imports from @clerk/nextjs/server via trpc/init
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "test-owner-1" }),
}));
// Mock the prisma singleton used by trpc/init
vi.mock("@/lib/prisma", () => ({
prisma: {},
}));
const TEST_OWNER_ID = "test-owner-1";
const OTHER_OWNER_ID = "other-owner-2";
const PET_ID = "pet-cuid-1";
const MOCK_PET = {
id: PET_ID,
name: "Buddy",
ownerId: TEST_OWNER_ID,
speciesId: "species-dog",
breedId: null,
bio: "A good boy",
adoptionStory: null,
avatarKey: null,
createdAt: new Date(),
updatedAt: new Date(),
};
/**
* Create a tRPC caller with a mock context.
* The caller bypasses HTTP entirely — calls procedure logic directly.
*/
function createTestCaller(userId: string, mockPrisma: ReturnType<typeof createMockPrisma>) {
// petsRouter.createCaller accepts the same context shape as createTRPCContext returns
return petsRouter.createCaller({
userId,
// Cast to PrismaClient — mock has the same interface shape for our tested methods
prisma: mockPrisma as unknown as Parameters<typeof petsRouter.createCaller>[0]["prisma"],
});
}
describe("PET-01: Pet creation", () => {
it.todo(
"pets.create stores a pet row in the DB with the owner's userId as ownerId"
);
let mockPrisma: ReturnType<typeof createMockPrisma>;
it.todo(
"pets.create validates input: name ≤ 30 chars, speciesId required, bio ≤ 150 chars optional"
);
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it.todo(
"pets.create upserts the owner row before inserting the pet (ensures Owner FK exists)"
);
it("pets.create stores a pet row in the DB with the owner's userId as ownerId", async () => {
mockPrisma.owner.upsert.mockResolvedValue({ id: TEST_OWNER_ID, createdAt: new Date(), pets: [] });
mockPrisma.pet.create.mockResolvedValue({
...MOCK_PET,
ownerId: TEST_OWNER_ID,
});
it.todo(
"pets.create returns the newly created pet with id, name, speciesId, ownerId"
);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.create({
name: "Buddy",
speciesId: "species-dog",
bio: "A good boy",
});
expect(result.name).toBe("Buddy");
expect(result.ownerId).toBe(TEST_OWNER_ID);
expect(mockPrisma.pet.create).toHaveBeenCalledOnce();
});
it("pets.create validates input: name > 30 chars throws ZodError", async () => {
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await expect(
caller.create({
name: "A".repeat(31), // exceeds max(30)
speciesId: "species-dog",
})
).rejects.toThrow();
});
it("pets.create upserts the owner row before inserting the pet (ensures Owner FK exists)", async () => {
mockPrisma.owner.upsert.mockResolvedValue({ id: TEST_OWNER_ID, createdAt: new Date(), pets: [] });
mockPrisma.pet.create.mockResolvedValue(MOCK_PET);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await caller.create({ name: "Buddy", speciesId: "species-dog" });
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({
where: { id: TEST_OWNER_ID },
update: {},
create: { id: TEST_OWNER_ID },
});
// Owner upsert must be called before pet create
expect(mockPrisma.owner.upsert).toHaveBeenCalledBefore(mockPrisma.pet.create);
});
it("pets.create returns the newly created pet with id, name, speciesId, ownerId", async () => {
mockPrisma.owner.upsert.mockResolvedValue({ id: TEST_OWNER_ID, createdAt: new Date(), pets: [] });
mockPrisma.pet.create.mockResolvedValue(MOCK_PET);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.create({ name: "Buddy", speciesId: "species-dog" });
expect(result).toMatchObject({
id: PET_ID,
name: "Buddy",
speciesId: "species-dog",
ownerId: TEST_OWNER_ID,
});
});
});
describe("PET-02: Ownership enforcement on update", () => {
it.todo(
"pets.update throws FORBIDDEN when the requesting userId does not match pet.ownerId"
);
let mockPrisma: ReturnType<typeof createMockPrisma>;
it.todo(
"pets.update succeeds when the requesting userId matches pet.ownerId"
);
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it("pets.update throws FORBIDDEN when the requesting userId does not match pet.ownerId", async () => {
// Pet belongs to OTHER_OWNER — different from caller
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: OTHER_OWNER_ID,
});
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await expect(
caller.update({ petId: PET_ID, name: "New Name" })
).rejects.toMatchObject({
code: "FORBIDDEN",
});
// Should NOT have updated
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
it("pets.update succeeds when the requesting userId matches pet.ownerId", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: TEST_OWNER_ID,
});
mockPrisma.pet.update.mockResolvedValue({
...MOCK_PET,
name: "Updated Name",
species: { id: "species-dog", name: "Dog" },
breed: null,
});
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.update({ petId: PET_ID, name: "Updated Name" });
expect(result.name).toBe("Updated Name");
expect(mockPrisma.pet.update).toHaveBeenCalledOnce();
});
});
describe("PET-03: Pet deletion", () => {
it.todo("pets.delete removes the pet row when called by the pet's owner");
describe("PET-03: Pet deletion by owner", () => {
let mockPrisma: ReturnType<typeof createMockPrisma>;
it.todo(
"pets.delete throws FORBIDDEN when called by a non-owner userId"
);
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it("pets.delete removes the pet row when called by the pet's owner", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: TEST_OWNER_ID,
});
mockPrisma.pet.delete.mockResolvedValue(MOCK_PET);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.delete({ petId: PET_ID });
expect(result).toEqual({ success: true });
expect(mockPrisma.pet.delete).toHaveBeenCalledWith({
where: { id: PET_ID },
});
});
it("pets.delete throws FORBIDDEN when called by a non-owner userId", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: OTHER_OWNER_ID,
});
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await expect(
caller.delete({ petId: PET_ID })
).rejects.toMatchObject({
code: "FORBIDDEN",
});
expect(mockPrisma.pet.delete).not.toHaveBeenCalled();
});
});
describe("PET-05: Adoption origin story", () => {
it.todo(
"adoptionStory field is persisted to the DB on pets.create (up to 500 chars)"
);
let mockPrisma: ReturnType<typeof createMockPrisma>;
it.todo(
"adoptionStory field is returned on pets.byId and included in pets.list response"
);
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it("adoptionStory field is persisted to the DB on pets.create (up to 500 chars)", async () => {
const story = "Found at the shelter on a rainy day.";
mockPrisma.owner.upsert.mockResolvedValue({ id: TEST_OWNER_ID, createdAt: new Date(), pets: [] });
mockPrisma.pet.create.mockResolvedValue({
...MOCK_PET,
adoptionStory: story,
});
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.create({
name: "Luna",
speciesId: "species-cat",
adoptionStory: story,
});
expect(result.adoptionStory).toBe(story);
// Verify the DB call passed adoptionStory in data
expect(mockPrisma.pet.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ adoptionStory: story }),
})
);
});
it("adoptionStory validates max 500 chars — exceeding throws ZodError", async () => {
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
await expect(
caller.create({
name: "Luna",
speciesId: "species-cat",
adoptionStory: "A".repeat(501),
})
).rejects.toThrow();
});
});
describe("Ownership assertion", () => {
it.todo(
"pets.byId throws FORBIDDEN when a different owner requests another owner's pet by id"
);
describe("Ownership assertion — non-owner cannot update", () => {
let mockPrisma: ReturnType<typeof createMockPrisma>;
it.todo(
"pets.list returns only pets belonging to the requesting owner (scoped by ownerId)"
);
beforeEach(() => {
mockPrisma = createMockPrisma();
vi.clearAllMocks();
});
it("pets.update throws FORBIDDEN (T-3-01) when a different owner supplies the same petId", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: OTHER_OWNER_ID, // owned by other
});
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma); // calling as test-owner
let thrownError: unknown;
try {
await caller.update({ petId: PET_ID, name: "Hacked Name" });
} catch (e) {
thrownError = e;
}
expect(thrownError).toBeInstanceOf(TRPCError);
expect((thrownError as TRPCError).code).toBe("FORBIDDEN");
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
});
});