Skip to content

Commit e8799bc

Browse files
authored
Merge pull request #42 from GTBitsOfGood/alexchen/create-item-mutation
create item mutation + tests
2 parents 07230f4 + ef5cd4d commit e8799bc

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed

src/app/api/items/route.test.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
//Lint gets angry when "as any" is used, but it is necessary for mocking Prisma responses using the "select" parameter (for now).
3+
import { testApiHandler } from "next-test-api-route-handler";
4+
import * as appHandler from "./route";
5+
6+
import { expect, test } from "@jest/globals";
7+
import { dbMock } from "@/test/dbMock";
8+
import { invalidateSession, validateSession } from "@/test/util/authMockUtils";
9+
import { UserType } from "@prisma/client";
10+
11+
const item = {
12+
title: "Some item",
13+
category: "Some category",
14+
quantity: 2,
15+
expirationDate: new Date(1000),
16+
unitSize: 64,
17+
unitType: "bunches",
18+
datePosted: new Date(1000),
19+
lotNumber: 2,
20+
donorName: "John Doe",
21+
unitPrice: 7,
22+
maxRequestLimit: "5",
23+
};
24+
const invalidItem = {
25+
title: "foobar",
26+
category: "baz",
27+
quantity: -1,
28+
donorName: 17.5,
29+
};
30+
const itemOutput = {
31+
title: "Some item",
32+
category: "Some category",
33+
quantity: 2,
34+
expirationDate: new Date(1000).toISOString(),
35+
unitSize: 64,
36+
datePosted: new Date(1000).toISOString(),
37+
lotNumber: 2,
38+
donorName: "John Doe",
39+
unitPrice: 7,
40+
unitType: "bunches",
41+
maxRequestLimit: "5",
42+
};
43+
44+
test("returns 401 on invalid session", async () => {
45+
await testApiHandler({
46+
appHandler,
47+
async test({ fetch }) {
48+
// Mock invalid session
49+
invalidateSession();
50+
const itemFormData = new FormData();
51+
Object.entries(item).forEach(([key, value]) =>
52+
itemFormData.append(key, value.toString())
53+
);
54+
const res = await fetch({ method: "POST", body: itemFormData });
55+
await expect(res.status).toBe(401);
56+
await expect(res.json()).resolves.toStrictEqual({
57+
message: "Session required",
58+
});
59+
},
60+
});
61+
});
62+
63+
test("returns 403 on unauthorized (partner)", async () => {
64+
await testApiHandler({
65+
appHandler,
66+
async test({ fetch }) {
67+
validateSession(UserType.PARTNER);
68+
const itemFormData = new FormData();
69+
Object.entries(item).forEach(([key, value]) =>
70+
itemFormData.append(key, value.toString())
71+
);
72+
const res = await fetch({ method: "POST", body: itemFormData });
73+
await expect(res.status).toBe(403);
74+
await expect(res.json()).resolves.toStrictEqual({
75+
message: "You are not allowed to add this record",
76+
});
77+
},
78+
});
79+
});
80+
81+
test("returns 403 on unauthorized (staff)", async () => {
82+
await testApiHandler({
83+
appHandler,
84+
async test({ fetch }) {
85+
validateSession(UserType.STAFF);
86+
const itemFormData = new FormData();
87+
Object.entries(item).forEach(([key, value]) =>
88+
itemFormData.append(key, value.toString())
89+
);
90+
const res = await fetch({ method: "POST", body: itemFormData });
91+
await expect(res.status).toBe(403);
92+
await expect(res.json()).resolves.toStrictEqual({
93+
message: "You are not allowed to add this record",
94+
});
95+
},
96+
});
97+
});
98+
99+
test("returns 400 on bad form data", async () => {
100+
await testApiHandler({
101+
appHandler,
102+
async test({ fetch }) {
103+
validateSession(UserType.ADMIN);
104+
const itemFormData = new FormData();
105+
Object.entries(invalidItem).forEach(([key, value]) =>
106+
itemFormData.append(key, value.toString())
107+
);
108+
const res = await fetch({ method: "POST", body: itemFormData });
109+
await expect(res.status).toBe(400);
110+
await expect(res.json()).resolves.toStrictEqual({
111+
message: "Invalid form data",
112+
});
113+
},
114+
});
115+
});
116+
117+
test("returns 200 and correctly creates item (admin)", async () => {
118+
await testApiHandler({
119+
appHandler,
120+
async test({ fetch }) {
121+
validateSession(UserType.ADMIN);
122+
const itemFormData = new FormData();
123+
Object.entries(item).forEach(([key, value]) =>
124+
itemFormData.append(key, value.toString())
125+
);
126+
dbMock.item.create.mockResolvedValueOnce(itemOutput as any);
127+
const res = await fetch({ method: "POST", body: itemFormData });
128+
await expect(res.status).toBe(200);
129+
await expect(res.json()).resolves.toStrictEqual(itemOutput);
130+
},
131+
});
132+
});

src/app/api/items/route.ts

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
authenticationError,
3+
authorizationError,
4+
argumentError,
5+
} from "@/util/responses";
6+
import { auth } from "@/auth";
7+
import { db } from "@/db";
8+
import { NextResponse, NextRequest } from "next/server";
9+
import { UserType } from "@prisma/client";
10+
import { z } from "zod";
11+
import { zfd } from "zod-form-data";
12+
13+
const AUTHORIZED_USER_TYPES = [
14+
UserType.ADMIN,
15+
UserType.SUPER_ADMIN,
16+
] as UserType[];
17+
18+
const ItemFormSchema = zfd.formData({
19+
title: zfd.text(),
20+
category: zfd.text(),
21+
quantity: zfd.numeric(z.number().int().min(0)),
22+
expirationDate: z.coerce.date(),
23+
unitSize: zfd.numeric(z.number().int().min(0)),
24+
unitType: zfd.text(),
25+
datePosted: z.coerce.date(),
26+
lotNumber: zfd.numeric(z.number().int().min(0)),
27+
donorName: zfd.text(),
28+
unitPrice: zfd.numeric(z.number().min(0)),
29+
maxRequestLimit: zfd.text(),
30+
});
31+
32+
interface ItemResponse {
33+
title: string;
34+
category: string;
35+
quantity: number;
36+
expirationDate: Date;
37+
unitSize: number;
38+
datePosted: Date;
39+
lotNumber: number;
40+
donorName: string;
41+
unitPrice: number;
42+
unitType: string;
43+
maxRequestLimit: string;
44+
}
45+
46+
/**
47+
* Creates a new item in the Items database.
48+
* Parameters are passed as form data.
49+
* @params title: Title of the item
50+
* @params category: Category of the item
51+
* @params quantity: Quantity of the item
52+
* @params expiration: Expiration date of the item
53+
* @params unitSize: Size per unit of the item
54+
* @params unitType: Type of unit of the item
55+
* @params datePosted: Date the item was posted to the database
56+
* @params lotNumber: Lot number of the item
57+
* @params donorName: Name of the donor of the item
58+
* @params unitPrice: Price per unit of the item
59+
* @params maxRequestLimit: Maximum number of requests allowed for the item
60+
* @returns 401 if the request is not authenticated
61+
* @returns 403 if the user is not authorized to view the partner details
62+
* @returns 400 if the form data is invalid
63+
* @returns 200 and the contents of the created item
64+
*/
65+
export async function POST(request: NextRequest): Promise<NextResponse> {
66+
const session = await auth();
67+
if (!session?.user) return authenticationError("Session required");
68+
if (!AUTHORIZED_USER_TYPES.includes(session.user.type)) {
69+
return authorizationError("You are not allowed to add this record");
70+
}
71+
const validatedForm = ItemFormSchema.safeParse(await request.formData());
72+
73+
if (!validatedForm.success) return argumentError("Invalid form data");
74+
75+
const createdItem = await db.item.create({
76+
data: validatedForm.data,
77+
});
78+
79+
return NextResponse.json(createdItem as ItemResponse);
80+
}

0 commit comments

Comments
 (0)