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:
1 parent
95dfec7bdf
commit
f996459133
1 file changed
+263
-42
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user