Skip to content

Commit 1e16bce

Browse files
authored
Merge pull request #38 from GTBitsOfGood/davidgu/CreateUnallocatedItemRequestMutation
Finished the unallocated item partner mutation
2 parents e20a54f + e68bf23 commit 1e16bce

File tree

4 files changed

+293
-3
lines changed

4 files changed

+293
-3
lines changed
+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { testApiHandler } from "next-test-api-route-handler";
2+
import * as appHandler from "./route";
3+
import { expect, test } from "@jest/globals";
4+
import { validateSession, invalidateSession } from "@/test/util/authMockUtils";
5+
import { dbMock } from "@/test/dbMock";
6+
import { createUnclaimedItem } from "@/test/util/dbMockUtils";
7+
8+
/**
9+
* Form data: {
10+
* unallocatedItemId: "1",
11+
* quantity: "1",
12+
* comment: "comment"
13+
* }
14+
* @returns FormData with good data by default, but can be modified
15+
*/
16+
function getFormData({
17+
unallocatedItemId = "1",
18+
quantity = "1",
19+
comment = "comment",
20+
}: {
21+
unallocatedItemId?: string;
22+
quantity?: string;
23+
comment?: string;
24+
} = {}) {
25+
const formData = new FormData();
26+
formData.append("unallocatedItemId", unallocatedItemId);
27+
formData.append("quantity", quantity);
28+
formData.append("comment", comment);
29+
return formData;
30+
}
31+
32+
test("Should return 401 for invalid session", async () => {
33+
await testApiHandler({
34+
appHandler,
35+
async test({ fetch }) {
36+
invalidateSession();
37+
38+
const res = await fetch({ method: "POST" });
39+
expect(res.status).toBe(401);
40+
const json = await res.json();
41+
expect(json).toEqual({ message: "Session required" });
42+
},
43+
});
44+
});
45+
46+
test("Should return 403 if not a partner", async () => {
47+
await testApiHandler({
48+
appHandler,
49+
async test({ fetch }) {
50+
validateSession("STAFF");
51+
52+
const res = await fetch({ method: "POST" });
53+
expect(res.status).toBe(403);
54+
const json = await res.json();
55+
expect(json).toEqual({ message: "Unauthorized" });
56+
},
57+
});
58+
});
59+
60+
test("Should return 404 if unallocated item not found", async () => {
61+
await testApiHandler({
62+
appHandler,
63+
async test({ fetch }) {
64+
validateSession("PARTNER");
65+
66+
dbMock.item.findUnique.mockResolvedValue(null);
67+
68+
const res = await fetch({ method: "POST", body: getFormData() });
69+
expect(res.status).toBe(404);
70+
const json = await res.json();
71+
expect(json).toEqual({ message: "Unallocated item not found" });
72+
},
73+
});
74+
});
75+
76+
test("Should return 400 for bad form data", async () => {
77+
await testApiHandler({
78+
appHandler,
79+
async test({ fetch }) {
80+
validateSession("PARTNER");
81+
82+
const badFormData = new FormData();
83+
badFormData.append("unallocatedItemIdBad", "1");
84+
badFormData.append("quantity", "1");
85+
badFormData.append("comment", "comment");
86+
87+
const res = await fetch({ method: "POST", body: badFormData });
88+
expect(res.status).toBe(400);
89+
const json = await res.json();
90+
expect(json).toEqual({ message: "Invalid form data" });
91+
},
92+
});
93+
});
94+
95+
test("Should return 400 for too low quantity", async () => {
96+
await testApiHandler({
97+
appHandler,
98+
async test({ fetch }) {
99+
validateSession("PARTNER");
100+
101+
const res = await fetch({
102+
method: "POST",
103+
body: getFormData({ quantity: "0" }),
104+
});
105+
expect(res.status).toBe(400);
106+
const json = await res.json();
107+
expect(json).toEqual({ message: "Invalid form data" });
108+
},
109+
});
110+
});
111+
112+
test("Should return 400 for requesting too many items", async () => {
113+
await testApiHandler({
114+
appHandler,
115+
async test({ fetch }) {
116+
validateSession("PARTNER");
117+
118+
dbMock.item.findUnique.mockResolvedValue(
119+
await createUnclaimedItem({ id: 1, quantity: 1 })
120+
);
121+
122+
const res = await fetch({
123+
method: "POST",
124+
body: getFormData({ quantity: "2" }),
125+
});
126+
expect(res.status).toBe(400);
127+
const json = await res.json();
128+
expect(json).toEqual({ message: "Not enough items for request" });
129+
},
130+
});
131+
});
132+
133+
test("Should return 200 for successful request", async () => {
134+
await testApiHandler({
135+
appHandler,
136+
async test({ fetch }) {
137+
validateSession("PARTNER");
138+
139+
dbMock.item.findUnique.mockResolvedValue(
140+
await createUnclaimedItem({ id: 1, quantity: 2 })
141+
);
142+
143+
const res = await fetch({ method: "POST", body: getFormData() });
144+
expect(res.status).toBe(200);
145+
},
146+
});
147+
});
148+
149+
test("Should create unallocated item request on success", async () => {
150+
await testApiHandler({
151+
appHandler,
152+
async test({ fetch }) {
153+
const session = await validateSession("PARTNER");
154+
155+
dbMock.item.findUnique.mockResolvedValue(
156+
await createUnclaimedItem({ id: 1, quantity: 2 })
157+
);
158+
159+
const res = await fetch({
160+
method: "POST",
161+
body: getFormData({
162+
unallocatedItemId: "1",
163+
quantity: "1",
164+
comment: "comment",
165+
}),
166+
});
167+
expect(res.status).toBe(200);
168+
169+
// For now, this is the only way I know to check if the create method was called
170+
expect(dbMock.unallocatedItemRequest.create).toHaveBeenCalledWith({
171+
data: {
172+
itemId: 1,
173+
partnerId: parseInt(session.user.id),
174+
quantity: 1,
175+
comments: "comment",
176+
},
177+
});
178+
},
179+
});
180+
});

src/app/api/unallocatedItems/route.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { auth } from "@/auth";
2+
import { db } from "@/db";
3+
import {
4+
argumentError,
5+
authenticationError,
6+
authorizationError,
7+
notFoundError,
8+
ok,
9+
} from "@/util/responses";
10+
import { UserType } from "@prisma/client";
11+
import { z } from "zod";
12+
import { zfd } from "zod-form-data";
13+
14+
const schema = zfd.formData({
15+
unallocatedItemId: zfd.numeric(z.number().int()),
16+
// priority: zfd.numeric(), Uncomment when priority is added to the schema
17+
quantity: zfd.numeric(z.number().int().min(1)), // Requesting 0 items would be stupid
18+
comment: zfd.text(),
19+
});
20+
21+
/**
22+
* Handles POST requests to create a new unallocated item request.
23+
* Uses form data unallocatedItemId, quantity, and comment.
24+
* @param req - the incoming request
25+
* @returns 200 if the request is successful
26+
* @returns 400 if the form data is invalid or there are not enough items for the request
27+
* @returns 401 if the session is invalid
28+
* @returns 403 if the user type isn't a partner
29+
* @returns 404 if the unallocated item is not found
30+
*/
31+
export async function POST(req: Request) {
32+
const session = await auth();
33+
if (!session) return authenticationError("Session required");
34+
if (!session?.user) return authenticationError("User not found");
35+
if (session.user.type !== UserType.PARTNER)
36+
return authorizationError("Unauthorized");
37+
38+
const parsed = schema.safeParse(await req.formData());
39+
if (!parsed.success) return argumentError("Invalid form data");
40+
// const { unallocatedItemId, priority, quantity, comment } = parsed.data;
41+
const { unallocatedItemId, quantity, comment } = parsed.data;
42+
43+
// Find unallocated item by id
44+
const unallocatedItem = await db.item.findUnique({
45+
where: { id: unallocatedItemId },
46+
});
47+
if (!unallocatedItem) return notFoundError("Unallocated item not found");
48+
49+
// Check if there are enough items to request
50+
if (quantity > unallocatedItem.quantity)
51+
return argumentError("Not enough items for request");
52+
53+
// Create unallocated item request
54+
db.unallocatedItemRequest.create({
55+
data: {
56+
itemId: unallocatedItemId,
57+
partnerId: parseInt(session.user.id),
58+
quantity: quantity,
59+
comments: comment,
60+
},
61+
});
62+
63+
return ok();
64+
}

src/test/util/authMockUtils.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { authMock } from "@/test/authMock";
22
import { UserType } from "@prisma/client";
3-
import { v4 as uuidv4 } from "uuid";
43

54
// Helper util methods for testing
65

@@ -13,7 +12,7 @@ export async function invalidateSession() {
1312

1413
/**
1514
* Helper method for validating a session
16-
* @param user Optional, default is { id: "1234", type: "ADMIN" }
15+
* @param user Optional, default is { id: randomId, type: "ADMIN" }
1716
* @param expires Optional, default is a day from now
1817
* @returns A session object with the user and expires fields
1918
*/
@@ -23,7 +22,7 @@ export async function validateSession(
2322
) {
2423
const createdSession = {
2524
user: {
26-
id: uuidv4(),
25+
id: "" + Math.floor(Math.random() * 10000),
2726
type: userType,
2827
},
2928
expires: expires.toISOString(),

src/test/util/dbMockUtils.ts

+47
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,50 @@ export async function fillDbMockWithUnallocatedItemRequestsForPartnerIdFilter(
8989
unallocatedItemRequests
9090
);
9191
}
92+
93+
/**
94+
* Helper method for creating a single unclaimed item.
95+
* Only the id and title are 100% untouchable by caller.
96+
*/
97+
export async function createUnclaimedItem({
98+
id = Math.floor(Math.random() * 10000),
99+
title = `Test Item ${id}`,
100+
category = "Test Category",
101+
quantity = 10,
102+
expirationDate = new Date(Date.now() + Math.floor(Math.random() * 10000)),
103+
unitSize = 1,
104+
unitType = "Test Unit",
105+
datePosted = new Date(),
106+
lotNumber = Math.floor(Math.random() * 10000),
107+
donorName = "Test Donor",
108+
unitPrice = 0,
109+
maxRequestLimit = "1",
110+
}: {
111+
id?: number;
112+
title?: string;
113+
category?: string;
114+
quantity?: number;
115+
expirationDate?: Date;
116+
unitSize?: number;
117+
unitType?: string;
118+
datePosted?: Date;
119+
lotNumber?: number;
120+
donorName?: string;
121+
unitPrice?: number;
122+
maxRequestLimit?: string;
123+
}): Promise<Item> {
124+
return {
125+
id,
126+
title,
127+
category,
128+
quantity,
129+
expirationDate,
130+
unitSize,
131+
unitType,
132+
datePosted,
133+
lotNumber,
134+
donorName,
135+
unitPrice,
136+
maxRequestLimit,
137+
};
138+
}

0 commit comments

Comments
 (0)