From 8a5ffe10c8c1988603db12509f0dbbba2351c615 Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:10:21 -0600 Subject: [PATCH 01/15] created 2 new post endpoints with orders from orderdb --- src/common/models.ts | 3 +- src/services/shop/shop-router.ts | 114 ++++++++++++++++++++++++++++++ src/services/shop/shop-schemas.ts | 50 +++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/common/models.ts b/src/common/models.ts index 9df77b9e..0470a1a2 100644 --- a/src/common/models.ts +++ b/src/common/models.ts @@ -8,7 +8,7 @@ import { MentorOfficeHours } from "../services/mentor/mentor-schemas"; import { Event, EventAttendance, EventFollowers } from "../services/event/event-schemas"; import { NewsletterSubscription } from "../services/newsletter/newsletter-schemas"; import { RegistrationApplication } from "../services/registration/registration-schemas"; -import { ShopItem } from "../services/shop/shop-schemas"; +import { ShopItem, ShopOrder } from "../services/shop/shop-schemas"; import { UserAttendance, UserFollowing, UserInfo } from "../services/user/user-schemas"; import { AnyParamConstructor, IModelOptions } from "@typegoose/typegoose/lib/types"; import { StaffShift } from "../services/staff/staff-schemas"; @@ -152,6 +152,7 @@ export default class Models { // Shop static ShopItem: Model = getModel(ShopItem, Group.SHOP, ShopCollection.ITEMS); + static ShopOrder: Model = getModel(ShopOrder, Group.SHOP, ShopCollection.ITEMS); // Staff static StaffShift: Model = getModel(StaffShift, Group.STAFF, StaffCollection.SHIFT); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 89d27c2a..6a87243f 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -14,6 +14,11 @@ import { ShopItemSchema, ShopItemsSchema, ShopItemUpdateRequestSchema, + ShopItemGenerateOrderSchema, + ShopItemFulfillOrderSchema, + SuccessSchema, + ShopOrder, + OrderQRCodesSchema, } from "./shop-schemas"; import { Router } from "express"; import { StatusCode } from "status-code-enum"; @@ -218,6 +223,115 @@ shopRouter.get( }, ); + +shopRouter.post( + "/item/generateorder", + specification({ + method: "post", + path: "/shop/item/generateorder/", + tag: Tag.SHOP, + role: null, + summary: "Generates order and returns qr code", + body: ShopItemGenerateOrderSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The qr codes", + schema: OrderQRCodesSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Item doesn't exist", + schema: ShopItemNotFoundErrorSchema, + }, + [StatusCode.ClientErrorBadRequest]: { + description: "Not enough quantity in shop", + schema: ShopInsufficientFundsErrorSchema, //potentially change + }, + }, + }), + async (req, res) => { + const body = ShopItemGenerateOrderSchema.parse(req.body) + const { items, quantity } = body; + + for(var i = 0; i < items.length; i++) { + //items[i] is the _id of the items + const item = await Models.ShopItem.findOne({ itemId: items[i] }); + + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + const q = quantity?.[i] as number | undefined; + if(q == undefined || item.quantity < q) { + // send which item there isn't enough of ? + return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientFundsError); + } + } + + //have availability of all item so can generate qr code with order number + const order = Math.floor(Math.random() * 10); ; + const qrCodeUrl = `hackillinois://ordernum?orderNum=${order}`; + + const shopOrder: ShopOrder = { + orderNum: order, + items: items, + quantity: quantity, + }; + + await Models.ShopOrder.create(shopOrder); + + return res.status(StatusCode.SuccessOK).send({ qrInfo: qrCodeUrl }); + }, +); + +shopRouter.post( + "/item/fulfillorder", + specification({ + method: "post", + path: "/shop/item/fulfillorder/", + tag: Tag.SHOP, + role: null, + summary: "Purchases the order item", + body: ShopItemFulfillOrderSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The successfully purchased order", + schema: SuccessSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Order doesn't exist", + schema: ShopItemNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + // when qr code is scanned, will call this so body needs to have order num and then i use that + // to get the order and then for each item in the order, subtract the quantity and then return success + const body = req.body; + const num = body.orderNum; + + const order = await Models.ShopOrder.findOne({ orderNum: num }); + + if(!order) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + for(var i = 0; i < order.items.length; i++) { + const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + + if(!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + const q = order.quantity?.[i] as number | 0; + item.quantity = item.quantity - q; + item.save(); + } + + return res.status(StatusCode.SuccessOK).json({ message: "success" }); + }, +); + + shopRouter.post( "/item/buy", specification({ diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 2303d433..2ed9740d 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -46,7 +46,33 @@ export class ShopItem { } } +export class ShopOrder { + @prop({ required: true }) + public orderNum: number + @prop({ required: true }) + public items: Array + + @prop({ required: true }) + public quantity: Array + + constructor( + orderNum: number, + items: Array, + quantity: Array, + ) { + this.orderNum = orderNum; + this.items = items; + this.quantity = quantity; + } +} + export const ShopItemIdSchema = z.string().openapi("ShopItemId", { example: "item1234" }); +// export const ShopOrderArraySchema = z +// .tuple([ +// z.array(z.string()), +// z.array(z.number()), +// ]) +// .openapi("ShopOrderArray", { example: [["item1234", "item5678"], [1, 2]] }); export const ShopItemSchema = z .object({ @@ -112,6 +138,30 @@ export const ShopItemBuyRequestSchema = z.object({ instance: z.string().openapi({ example: "1x3" }), }); +// needs to have list of items and quantity +export const ShopItemGenerateOrderSchema = z.object({ + items: z.array(z.string()), + quantity: z.array(z.number()), +}); + +export const ShopItemFulfillOrderSchema = z.object({ + orderNum: z.number(), +}); + +export const OrderQRCodeSchema = z.string().openapi("OrderQRCode", { + example: "hackillinois://ordernum?orderNum=10", +}); + +export const OrderQRCodesSchema = z + .object({ + qrInfo: z.string(OrderQRCodeSchema), + }) + .openapi("OrderQRCodes"); + +export const SuccessSchema = z.object({ + message: z.string(), +}).openapi("Success"); + export const [ShopItemAlreadyExistsError, ShopItemAlreadyExistsErrorSchema] = CreateErrorAndSchema({ error: "AlreadyExists", message: "An item with that id already exists, did you mean to update it instead?", From 1bdd7996e5ada3a8cf94766562d2e3fe8b3a513d Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:47:20 -0600 Subject: [PATCH 02/15] fixed lint errors --- src/services/shop/shop-router.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 6a87243f..6972b7cc 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -252,7 +252,7 @@ shopRouter.post( const body = ShopItemGenerateOrderSchema.parse(req.body) const { items, quantity } = body; - for(var i = 0; i < items.length; i++) { + for(let i = 0; i < items.length; i++) { //items[i] is the _id of the items const item = await Models.ShopItem.findOne({ itemId: items[i] }); @@ -268,7 +268,8 @@ shopRouter.post( } //have availability of all item so can generate qr code with order number - const order = Math.floor(Math.random() * 10); ; + const RAND_NUM = 10; + const order = Math.floor(Math.random() * RAND_NUM); const qrCodeUrl = `hackillinois://ordernum?orderNum=${order}`; const shopOrder: ShopOrder = { @@ -315,7 +316,7 @@ shopRouter.post( return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - for(var i = 0; i < order.items.length; i++) { + for(let i = 0; i < order.items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); if(!item) { @@ -324,7 +325,7 @@ shopRouter.post( const q = order.quantity?.[i] as number | 0; item.quantity = item.quantity - q; - item.save(); + await item.save(); } return res.status(StatusCode.SuccessOK).json({ message: "success" }); From cf40ea7409d789733ef2f22dbdd7bb22278d4f5f Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Wed, 4 Dec 2024 21:07:37 -0600 Subject: [PATCH 03/15] fixed minor issues --- src/common/models.ts | 3 ++- src/services/shop/shop-router.ts | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/common/models.ts b/src/common/models.ts index 0470a1a2..347ed9d9 100644 --- a/src/common/models.ts +++ b/src/common/models.ts @@ -73,6 +73,7 @@ enum RegistrationCollection { enum ShopCollection { ITEMS = "items", + ORDERS = "orders" } enum StaffCollection { @@ -152,7 +153,7 @@ export default class Models { // Shop static ShopItem: Model = getModel(ShopItem, Group.SHOP, ShopCollection.ITEMS); - static ShopOrder: Model = getModel(ShopOrder, Group.SHOP, ShopCollection.ITEMS); + static ShopOrder: Model = getModel(ShopOrder, Group.SHOP, ShopCollection.ORDERS); // Staff static StaffShift: Model = getModel(StaffShift, Group.STAFF, StaffCollection.SHIFT); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 6972b7cc..5304ff9d 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -193,7 +193,7 @@ shopRouter.get( method: "get", path: "/shop/item/qr/{id}/", tag: Tag.SHOP, - role: Role.STAFF, + role: null, //Role.STAFF summary: "Gets the QR codes for a shop item", parameters: z.object({ id: ShopItemIdSchema, @@ -249,8 +249,13 @@ shopRouter.post( }, }), async (req, res) => { - const body = ShopItemGenerateOrderSchema.parse(req.body) - const { items, quantity } = body; + const body = req.body; + const items = body.items; + const quantity = body.quantity + + + //const body = ShopItemGenerateOrderSchema.parse(req.body) + //const { items, quantity } = body; for(let i = 0; i < items.length; i++) { //items[i] is the _id of the items @@ -324,8 +329,17 @@ shopRouter.post( } const q = order.quantity?.[i] as number | 0; - item.quantity = item.quantity - q; - await item.save(); + + const updatedItem = await Models.ShopItem.findOneAndUpdate({ itemId: order.items[i] }, body, { + quantity: item.quantity - q, + }); + + if (!updatedItem) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + //item.quantity = item.quantity - q; + //await item.save(); } return res.status(StatusCode.SuccessOK).json({ message: "success" }); From 233abe3d89e7993fdc77627504347143a4eff90a Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Wed, 22 Jan 2025 20:15:48 -0600 Subject: [PATCH 04/15] uuid changes --- src/services/shop/shop-router.ts | 62 +++++++++++++++++++++++-------- src/services/shop/shop-schemas.ts | 11 ++++-- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 5304ff9d..ebad7763 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -68,7 +68,7 @@ shopRouter.post( method: "post", path: "/shop/item/", tag: Tag.SHOP, - role: Role.ADMIN, + role: null, summary: "Creates a shop item", body: ShopItemCreateRequestSchema, responses: { @@ -193,7 +193,7 @@ shopRouter.get( method: "get", path: "/shop/item/qr/{id}/", tag: Tag.SHOP, - role: null, //Role.STAFF + role: null, summary: "Gets the QR codes for a shop item", parameters: z.object({ id: ShopItemIdSchema, @@ -224,6 +224,7 @@ shopRouter.get( ); +//MINE shopRouter.post( "/item/generateorder", specification({ @@ -231,7 +232,7 @@ shopRouter.post( path: "/shop/item/generateorder/", tag: Tag.SHOP, role: null, - summary: "Generates order and returns qr code", + summary: "Generates an order and returns a qr code", body: ShopItemGenerateOrderSchema, responses: { [StatusCode.SuccessOK]: { @@ -244,19 +245,19 @@ shopRouter.post( }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", - schema: ShopInsufficientFundsErrorSchema, //potentially change + schema: ShopInsufficientFundsErrorSchema, }, }, }), async (req, res) => { const body = req.body; const items = body.items; - const quantity = body.quantity - + const quantity = body.quantity; - //const body = ShopItemGenerateOrderSchema.parse(req.body) - //const { items, quantity } = body; + //const payload = res.locals.payload as JwtPayload; + //const userId = payload.id; + //check if enough quantity in shop for(let i = 0; i < items.length; i++) { //items[i] is the _id of the items const item = await Models.ShopItem.findOne({ itemId: items[i] }); @@ -267,20 +268,42 @@ shopRouter.post( const q = quantity?.[i] as number | undefined; if(q == undefined || item.quantity < q) { - // send which item there isn't enough of ? return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientFundsError); } } - //have availability of all item so can generate qr code with order number - const RAND_NUM = 10; - const order = Math.floor(Math.random() * RAND_NUM); + //check if user has enough coins + /* + var currPrice = 0; + for(let i = 0; i < items.length; i++) { + const item = await Models.ShopItem.findOne({ itemId: items[i] }); + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + currPrice += item.price; + + const profile = await Models.AttendeeProfile.findOne({ userId: userId }); + if (!profile) { + throw Error("Could not find attendee profile"); + } + + if (profile.coins < currPrice) { + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + } + } + */ + + //have availability of all item and user has enough coins so can generate qr code with order number + const { v4: uuidv4 } = require('uuid'); + const order = uuidv4(); const qrCodeUrl = `hackillinois://ordernum?orderNum=${order}`; const shopOrder: ShopOrder = { orderNum: order, items: items, quantity: quantity, + userId: "userId", }; await Models.ShopOrder.create(shopOrder); @@ -289,6 +312,7 @@ shopRouter.post( }, ); +//MINE shopRouter.post( "/item/fulfillorder", specification({ @@ -296,7 +320,7 @@ shopRouter.post( path: "/shop/item/fulfillorder/", tag: Tag.SHOP, role: null, - summary: "Purchases the order item", + summary: "Purchases the order", body: ShopItemFulfillOrderSchema, responses: { [StatusCode.SuccessOK]: { @@ -320,6 +344,12 @@ shopRouter.post( if(!order) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } + /* + const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); + if (!profile) { + throw Error("Could not find attendee profile"); + } + */ for(let i = 0; i < order.items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); @@ -333,13 +363,13 @@ shopRouter.post( const updatedItem = await Models.ShopItem.findOneAndUpdate({ itemId: order.items[i] }, body, { quantity: item.quantity - q, }); - + if (!updatedItem) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - //item.quantity = item.quantity - q; - //await item.save(); + //update coins in user + //await updateCoins(order.userId, -item.price).then(console.error); } return res.status(StatusCode.SuccessOK).json({ message: "success" }); diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 2ed9740d..4c5271a4 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -48,21 +48,26 @@ export class ShopItem { export class ShopOrder { @prop({ required: true }) - public orderNum: number + public orderNum: string @prop({ required: true }) public items: Array @prop({ required: true }) public quantity: Array + @prop({ required: true }) + public userId: string + constructor( - orderNum: number, + orderNum: string, items: Array, quantity: Array, + userId: string, ) { this.orderNum = orderNum; this.items = items; this.quantity = quantity; + this.userId = userId; } } @@ -145,7 +150,7 @@ export const ShopItemGenerateOrderSchema = z.object({ }); export const ShopItemFulfillOrderSchema = z.object({ - orderNum: z.number(), + orderNum: z.string(), }); export const OrderQRCodeSchema = z.string().openapi("OrderQRCode", { From b3d48b3515240e0db70c5a84d551316dcef710e1 Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:24:07 -0600 Subject: [PATCH 05/15] adding for loop --- src/services/shop/shop-router.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index ebad7763..8806e29f 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -331,6 +331,10 @@ shopRouter.post( description: "Order doesn't exist", schema: ShopItemNotFoundErrorSchema, }, + [StatusCode.ClientErrorBadRequest]: { + description: "Not enough quantity in shop", + schema: ShopInsufficientFundsErrorSchema, + }, }, }), async (req, res) => { @@ -351,6 +355,21 @@ shopRouter.post( } */ + for(let i = 0; i < order.items.length; i++) { + + const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + + if(!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + const q = order.quantity?.[i] as number | 0; + + if(q == undefined || item.quantity < q) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientFundsError); + } + } + for(let i = 0; i < order.items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); From 4b3eb7aea06045a98850c6769575d89d9c057efb Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:44:14 -0600 Subject: [PATCH 06/15] minor changes --- src/common/models.ts | 2 +- src/services/shop/shop-router.ts | 67 ++++++++++++++----------------- src/services/shop/shop-schemas.ts | 20 +++++---- yarn.lock | 25 ++++++------ 4 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/common/models.ts b/src/common/models.ts index 017930e1..19a4f275 100644 --- a/src/common/models.ts +++ b/src/common/models.ts @@ -74,7 +74,7 @@ enum RegistrationCollection { enum ShopCollection { ITEMS = "items", - ORDERS = "orders" + ORDERS = "orders", } enum StaffCollection { diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 467dd602..4d1a3138 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -1,4 +1,3 @@ - import crypto from "crypto"; //ShopItemBuyRequestSchema import { @@ -12,7 +11,6 @@ import { ShopItemIdSchema, ShopItemNotFoundError, ShopItemNotFoundErrorSchema, - ShopItemQRCodesSchema, ShopItemSchema, ShopItemsSchema, ShopItemUpdateRequestSchema, @@ -219,10 +217,10 @@ shopRouter.post( // to get the order and then for each item in the order, subtract the quantity and then return success const body = req.body; const num = body.userId; - + const order = await Models.ShopOrder.findOne({ userId: num }); - if(!order) { + if (!order) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } @@ -231,26 +229,24 @@ shopRouter.post( throw Error("Could not find attendee profile"); } - - for(let i = 0; i < order.items.length; i++) { - + for (let i = 0; i < order.items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); - if(!item) { + if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } const q = order.quantity?.[i] as number | 0; - if(q == undefined || item.quantity < q) { + if (q == undefined || item.quantity < q) { return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientFundsError); } } - for(let i = 0; i < order.items.length; i++) { + for (let i = 0; i < order.items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); - if(!item) { + if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } @@ -274,7 +270,7 @@ shopRouter.post( } //update coins in user - await updatePoints(order.userId, -(q*item.price)).then(console.error); + await updatePoints(order.userId, -(q * item.price)).then(console.error); } const result = await Models.ShopOrder.deleteOne({ userId: num }); @@ -313,13 +309,12 @@ shopRouter.post( }, }), async (req, res) => { - const { itemId } = req.params; const { id: userId } = getAuthenticatedUser(req); var userOrder = await Models.ShopOrder.findOne({ userId: userId }); //user doesn't have a order yet - if(!userOrder) { + if (!userOrder) { const shopOrder: ShopOrder = { items: [], quantity: [], @@ -330,8 +325,8 @@ shopRouter.post( userOrder = await Models.ShopOrder.findOne({ userId: userId }); } - if(!userOrder){ - throw Error("Creating cart for user failed.") + if (!userOrder) { + throw Error("Creating cart for user failed."); } //check if enough quantity in shop @@ -340,7 +335,7 @@ shopRouter.post( return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - if(item.quantity <= 0) { + if (item.quantity <= 0) { return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); } @@ -357,14 +352,14 @@ shopRouter.post( //add item to order or increase quantity const items = userOrder.items; var found = false; - for(let i = 0; i < items.length; i++) { - if(items[i] = itemId) { + for (let i = 0; i < items.length; i++) { + if ((items[i] = itemId)) { found = true; const updatedShopOrder = await Models.ShopOrder.updateOne( { userId: userId }, - { - $inc: { [`quantity.${i}`]: 1 } + { + $inc: { [`quantity.${i}`]: 1 }, }, ); if (!updatedShopOrder) { @@ -372,17 +367,17 @@ shopRouter.post( } } } - if(!found) { + if (!found) { const updatedShopOrder = await Models.ShopOrder.updateOne( { userId: userId }, { $push: { items: itemId, - quantity: 1 - } + quantity: 1, + }, }, ); - + if (!updatedShopOrder) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } @@ -420,7 +415,7 @@ shopRouter.get( //get their order from order db var userOrder = await Models.ShopOrder.findOne({ userId: userId }); - if(!userOrder) { + if (!userOrder) { const shopOrder: ShopOrder = { items: [], quantity: [], @@ -431,8 +426,8 @@ shopRouter.get( userOrder = await Models.ShopOrder.findOne({ userId: userId }); } - if(!userOrder) { - throw Error("Unable to view cart.") + if (!userOrder) { + throw Error("Unable to view cart."); } const items = userOrder.items; @@ -455,11 +450,10 @@ shopRouter.get( } */ - return res.status(StatusCode.SuccessOK).send({ items: items, quantity: quantity}); + return res.status(StatusCode.SuccessOK).send({ items: items, quantity: quantity }); }, ); - shopRouter.get( "/cart/qr", specification({ @@ -487,13 +481,13 @@ shopRouter.get( const { id: userId } = getAuthenticatedUser(req); const userOrder = await Models.ShopOrder.findOne({ userId: userId }); - if(!userOrder) { + if (!userOrder) { throw Error("no order"); } const items = userOrder.items; const quantity = userOrder.quantity; //check if enough quantity in shop - for(let i = 0; i < items.length; i++) { + for (let i = 0; i < items.length; i++) { //items[i] is the _id of the items const item = await Models.ShopItem.findOne({ itemId: items[i] }); @@ -502,21 +496,21 @@ shopRouter.get( } const q = quantity?.[i] as number | undefined; - if(q == undefined || item.quantity < q) { + if (q == undefined || item.quantity < q) { return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); } } //check if user has enough coins var currPrice = 0; - for(let i = 0; i < items.length; i++) { + for (let i = 0; i < items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: items[i] }); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } currPrice += item.price; - + const profile = await Models.AttendeeProfile.findOne({ userId: userId }); if (!profile) { throw Error("Could not find attendee profile"); @@ -534,10 +528,9 @@ shopRouter.get( }, ); - function getRand(index: number): string { const hash = crypto.createHash("sha256").update(`${Config.JWT_SECRET}|${index}`).digest("hex"); return hash; } -export default shopRouter; \ No newline at end of file +export default shopRouter; diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 73873f9d..a953c483 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -48,19 +48,15 @@ export class ShopItem { export class ShopOrder { @prop({ required: true }) - public items: Array + public items: Array; @prop({ required: true }) - public quantity: Array + public quantity: Array; @prop({ required: true }) - public userId: string + public userId: string; - constructor( - items: Array, - quantity: Array, - userId: string, - ) { + constructor(items: Array, quantity: Array, userId: string) { this.items = items; this.quantity = quantity; this.userId = userId; @@ -159,9 +155,11 @@ export const OrderQRCodesSchema = z }) .openapi("OrderQRCodes"); -export const SuccessSchema = z.object({ - message: z.string(), -}).openapi("Success"); +export const SuccessSchema = z + .object({ + message: z.string(), + }) + .openapi("Success"); export const [ShopItemAlreadyExistsError, ShopItemAlreadyExistsErrorSchema] = CreateErrorAndSchema({ error: "AlreadyExists", diff --git a/yarn.lock b/yarn.lock index b0006b40..9b7e8a0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2338,9 +2338,9 @@ integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== "@types/node@*": - version "22.10.8" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.8.tgz#e7e2602c83d27d483c056302d76b86321c4e8697" - integrity sha512-rk+QvAEGsbX/ZPiiyel6hJHNUS9cnSbPWVaZLvE+Er3tLqQFzWMz9JOfWW7XUmKvRPfxJfbl3qYWve+RGXncFw== + version "22.12.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.12.0.tgz#bf8af3b2af0837b5a62a368756ff2b705ae0048c" + integrity sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA== dependencies: undici-types "~6.20.0" @@ -6138,11 +6138,6 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -queue-tick@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" - integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== - quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" @@ -6354,11 +6349,16 @@ semver@^7.3.5, semver@^7.3.8, semver@^7.5.3: dependencies: lru-cache "^6.0.0" -semver@^7.5.4, semver@^7.6.3: +semver@^7.5.4: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== +semver@^7.6.3: + version "7.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.0.tgz#9c6fe61d0c6f9fa9e26575162ee5a9180361b09c" + integrity sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ== + send@^1.0.0, send@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/send/-/send-1.1.0.tgz" @@ -6564,12 +6564,11 @@ stream-shift@^1.0.0: integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== streamx@^2.15.0: - version "2.21.1" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.21.1.tgz#f02979d8395b6b637d08a589fb514498bed55845" - integrity sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw== + version "2.22.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.22.0.tgz#cd7b5e57c95aaef0ff9b2aef7905afa62ec6e4a7" + integrity sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw== dependencies: fast-fifo "^1.3.2" - queue-tick "^1.0.1" text-decoder "^1.1.0" optionalDependencies: bare-events "^2.2.0" From e8ff79705e44f0afc12e644e26a8418401c913cb Mon Sep 17 00:00:00 2001 From: ananyaanand4 <112522595+ananyaanand4@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:49:39 -0600 Subject: [PATCH 07/15] fixed errors --- src/services/shop/shop-router.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 4d1a3138..d50e8521 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -275,7 +275,7 @@ shopRouter.post( const result = await Models.ShopOrder.deleteOne({ userId: num }); if (result.deletedCount === 0) { - return res.status(404).json({ message: "Order not found" }); + return res.status(StatusCode.ClientErrorNotFound).json({ message: "Not able to clear cart" }); } return res.status(StatusCode.SuccessOK).json({ message: "success" }); @@ -312,7 +312,7 @@ shopRouter.post( const { itemId } = req.params; const { id: userId } = getAuthenticatedUser(req); - var userOrder = await Models.ShopOrder.findOne({ userId: userId }); + let userOrder = await Models.ShopOrder.findOne({ userId: userId }); //user doesn't have a order yet if (!userOrder) { const shopOrder: ShopOrder = { @@ -351,7 +351,7 @@ shopRouter.post( //add item to order or increase quantity const items = userOrder.items; - var found = false; + let found = false; for (let i = 0; i < items.length; i++) { if ((items[i] = itemId)) { found = true; @@ -414,7 +414,7 @@ shopRouter.get( const { id: userId } = getAuthenticatedUser(req); //get their order from order db - var userOrder = await Models.ShopOrder.findOne({ userId: userId }); + let userOrder = await Models.ShopOrder.findOne({ userId: userId }); if (!userOrder) { const shopOrder: ShopOrder = { items: [], @@ -502,7 +502,7 @@ shopRouter.get( } //check if user has enough coins - var currPrice = 0; + let currPrice = 0; for (let i = 0; i < items.length; i++) { const item = await Models.ShopItem.findOne({ itemId: items[i] }); if (!item) { From c1ef1a6e7b5c7acd526d3394ccdee56f17dcc201 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:21:17 -0600 Subject: [PATCH 08/15] error codes and tests --- src/services/shop/shop-router.test.ts | 359 ++++++++++++++++++++++++++ src/services/shop/shop-router.ts | 218 +++++----------- src/services/shop/shop-schemas.ts | 26 +- 3 files changed, 437 insertions(+), 166 deletions(-) create mode 100644 src/services/shop/shop-router.test.ts diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts new file mode 100644 index 00000000..a967fc37 --- /dev/null +++ b/src/services/shop/shop-router.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import { getAsAttendee, postAsAttendee, postAsStaff, TESTER } from "../../common/testTools"; +import { StatusCode } from "status-code-enum"; +import Models from "../../common/models"; +import { ShopItem, ShopOrder } from "./shop-schemas"; +import { AttendeeProfile } from "../profile/profile-schemas"; + +const TESTER_SHOP_ITEM = { + itemId: "test-item-1", + name: "Test Item", + price: 100, + isRaffle: true, + imageURL: "test.jpg", + quantity: 10, +} satisfies ShopItem; + +const TESTER_SHOP_ORDER = { + userId: TESTER.id, + items: ["test-item-1"], + quantity: [2], +} satisfies ShopOrder; + +const TESTER_PROFILE = { + userId: TESTER.id, + displayName: TESTER.name, + avatarUrl: TESTER.avatarUrl, + discordTag: TESTER.discordTag, + points: 1000, + foodWave: 1, +} satisfies AttendeeProfile; + +// Initialize test data before each test +beforeEach(async () => { + // Clean up any existing data + await Models.ShopItem.deleteMany({}); + await Models.ShopOrder.deleteMany({}); + await Models.AttendeeProfile.deleteMany({}); + + // Create fresh test data + await Models.ShopItem.create(TESTER_SHOP_ITEM); + await Models.ShopOrder.create(TESTER_SHOP_ORDER); + await Models.AttendeeProfile.create(TESTER_PROFILE); +}); + +describe("POST /shop/cart/redeem", () => { + it("allows staff to successfully redeem an order", async () => { + const response = await postAsStaff("/shop/cart/redeem") + .send({ userId: TESTER_PROFILE.userId }) + .expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + message: "Success", + }); + + // Verify inventory was updated + const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); + expect(updatedItem?.quantity).toBe(8); + + // Verify points were deducted + const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedProfile?.points).toBe(800); + + // Verify order was deleted + const deletedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(deletedOrder).toBeNull(); + }); + + it("returns NotFound for non-existent order", async () => { + await postAsStaff("/shop/cart/redeem").send({ userId: "non-existent-user" }).expect(StatusCode.ClientErrorNotFound); + }); + + it("returns NotFound for non-existent user profile", async () => { + // Create order but delete profile + await Models.AttendeeProfile.deleteOne({ userId: TESTER_PROFILE.userId }); + + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + }); + + it("returns NotFound for non-existent shop item", async () => { + // Create order with non-existent item + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["non-existent-item"], quantity: [1] }); + + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + }); + + it("returns BadRequest for insufficient item quantity", async () => { + // Update order to request more items than available + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { quantity: [11] }, // TESTER_SHOP_ITEM presumably has 10 quantity + ); + + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + }); + + it("returns PaymentRequired for insufficient points", async () => { + // Update profile to have insufficient points + await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 0 }); + + await postAsStaff("/shop/cart/redeem") + .send({ userId: TESTER_PROFILE.userId }) + .expect(StatusCode.ClientErrorPaymentRequired); + }); + + it("handles undefined quantity correctly", async () => { + // Create order with undefined quantity + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { $unset: { quantity: 1 } }); + + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); + + // Should treat undefined quantity as 0 + const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); + expect(updatedItem?.quantity).toBe(TESTER_SHOP_ITEM.quantity); // Quantity shouldn't change + }); + + it("handles multiple items in order correctly", async () => { + // Create second test item + const secondItem = { + ...TESTER_SHOP_ITEM, + itemId: "test-item-2", + price: 50, + }; + await Models.ShopItem.create(secondItem); + + // Update order to include multiple items + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { + items: [TESTER_SHOP_ITEM.itemId, secondItem.itemId], + quantity: [1, 2], + }, + ); + + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); + + // Verify all items were updated correctly + const updatedItem1 = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); + const updatedItem2 = await Models.ShopItem.findOne({ itemId: secondItem.itemId }); + expect(updatedItem1?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 1); + expect(updatedItem2?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 2); + + // Verify total points deduction (TESTER_SHOP_ITEM.price * 1 + secondItem.price * 2) + const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); + const expectedPoints = TESTER_PROFILE.points - (TESTER_SHOP_ITEM.price + secondItem.price * 2); + expect(updatedProfile?.points).toBe(expectedPoints); + }); +}); + +describe("POST /shop/cart/:itemId", () => { + it("allows user to add new item to cart", async () => { + const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + message: "success", + }); + + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); + expect(updatedOrder?.quantity[0]).toBe(3); + }); + + it("increases quantity when adding existing item to cart", async () => { + // First addition + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + // Second addition + const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + message: "success", + }); + + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); + expect(updatedOrder?.quantity[0]).toBe(4); + }); + + it("returns NotFound for non-existent item", async () => { + await postAsAttendee("/shop/cart/non-existent-item").expect(StatusCode.ClientErrorNotFound); + }); + + it("returns BadRequest when insufficient shop quantity", async () => { + await Models.ShopItem.create({ + ...TESTER_SHOP_ITEM, + itemId: "out-of-stock-item", + quantity: 0, + }); + + await postAsAttendee("/shop/cart/out-of-stock-item").expect(StatusCode.ClientErrorBadRequest); + }); + + it("returns PaymentRequired when insufficient points", async () => { + await Models.ShopItem.create({ + ...TESTER_SHOP_ITEM, + itemId: "expensive-item", + price: 2000, + quantity: 1, + }); + + await postAsAttendee("/shop/cart/expensive-item").expect(StatusCode.ClientErrorPaymentRequired); + }); + + it("creates new cart if user doesn't have one", async () => { + // Delete any existing cart + await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); + + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + const newOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(newOrder).toBeTruthy(); + expect(newOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); + expect(newOrder?.quantity[0]).toBe(1); + }); +}); + +describe("GET /shop/cart", () => { + it("returns user's cart contents", async () => { + const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + items: [TESTER_SHOP_ITEM.itemId], + quantity: [2], + }); + }); + + it("creates and returns empty cart for new user", async () => { + // Ensure no existing cart + await Models.ShopOrder.findOneAndDelete({ userId: TESTER.id }); + + const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + items: [], + quantity: [], + }); + }); + + it("returns cart with matching items and quantity arrays", async () => { + await Models.ShopOrder.findOneAndDelete({ userId: TESTER.id }); + const shopOrder = { + userId: TESTER.id, + items: [TESTER_SHOP_ITEM.itemId, "test-item-2"], + quantity: [1, 3], + }; + await Models.ShopOrder.create(shopOrder); + + const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); + const cart = JSON.parse(response.text); + + expect(cart).toMatchObject({ + items: [TESTER_SHOP_ITEM.itemId, "test-item-2"], + quantity: [1, 3], + }); + }); +}); + +describe("GET /shop/cart/qr", () => { + it("returns QR code URL for valid cart", async () => { + const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + qrInfo: `hackillinois://userId?userId=${TESTER_PROFILE.userId}`, + }); + }); + + it("returns NotFound for non-existent cart", async () => { + await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); + await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorNotFound); + }); + + it("returns NotFound when cart item no longer exists in shop", async () => { + // Create cart with non-existent item + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["non-existent-item"], quantity: [1] }); + + const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorNotFound); + + expect(JSON.parse(response.text)).toMatchObject({ + error: expect.any(String), + }); + }); + + it("returns BadRequest when insufficient shop quantity", async () => { + // Create item with low quantity + const lowQuantityItem = { + ...TESTER_SHOP_ITEM, + itemId: "low-quantity-item", + quantity: 1, + }; + await Models.ShopItem.create(lowQuantityItem); + + // Create cart requesting more than available + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["low-quantity-item"], quantity: [2] }); + + const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorBadRequest); + + expect(JSON.parse(response.text)).toMatchObject({ + error: expect.any(String), + }); + }); + + it("returns PaymentRequired when insufficient points for total cart", async () => { + // Create expensive items + const expensiveItem1 = { + ...TESTER_SHOP_ITEM, + itemId: "expensive-item-1", + price: 500, + quantity: 2, + }; + const expensiveItem2 = { + ...TESTER_SHOP_ITEM, + itemId: "expensive-item-2", + price: 600, + quantity: 2, + }; + await Models.ShopItem.create(expensiveItem1); + await Models.ShopItem.create(expensiveItem2); + + // Create cart with multiple expensive items + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { + items: ["expensive-item-1", "expensive-item-2"], + quantity: [1, 1], + }, + ); + + // Set user points to less than total cart value + await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 1000 }); + + const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorPaymentRequired); + + expect(JSON.parse(response.text)).toMatchObject({ + error: expect.any(String), + }); + }); + + it("succeeds when user has exactly enough points", async () => { + // Create item with known price + const item = { + ...TESTER_SHOP_ITEM, + itemId: "exact-price-item", + price: 100, + quantity: 1, + }; + await Models.ShopItem.create(item); + + // Create cart with the item + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["exact-price-item"], quantity: [1] }); + + // Set user points to exact amount needed + await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 100 }); + + const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + qrInfo: `hackillinois://userId?userId=${TESTER_PROFILE.userId}`, + }); + }); +}); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index d50e8521..d60f9075 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -1,13 +1,8 @@ -import crypto from "crypto"; -//ShopItemBuyRequestSchema import { ShopInsufficientFundsError, ShopInsufficientFundsErrorSchema, ShopInsufficientQuantityErrorSchema, ShopItem, - ShopItemAlreadyExistsError, - ShopItemAlreadyExistsErrorSchema, - ShopItemCreateRequestSchema, ShopItemIdSchema, ShopItemNotFoundError, ShopItemNotFoundErrorSchema, @@ -19,10 +14,12 @@ import { SuccessSchema, ShopOrder, OrderQRCodesSchema, + ShopInsufficientQuantityError, + ShopOrderNotFoundError, + ShopOrderNotFoundErrorSchema, } from "./shop-schemas"; import { Router } from "express"; import { StatusCode } from "status-code-enum"; -import Config from "../../common/config"; import Models from "../../common/models"; import { Role } from "../auth/auth-schemas"; import specification, { Tag } from "../../middleware/specification"; @@ -30,6 +27,7 @@ import { z } from "zod"; import { SuccessResponseSchema } from "../../common/schemas"; import { updatePoints } from "../profile/profile-lib"; import { getAuthenticatedUser } from "../../common/auth"; +import { UserNotFoundError } from "../user/user-schemas"; const shopRouter = Router(); shopRouter.get( @@ -49,64 +47,7 @@ shopRouter.get( }), async (_req, res) => { const shopItems: ShopItem[] = await Models.ShopItem.find(); - - const withoutInstances = shopItems.map((item: ShopItem) => ({ - itemId: item.itemId, - name: item.name, - price: item.price, - isRaffle: item.isRaffle, - quantity: item.quantity, - imageURL: item.imageURL, - })); - - return res.status(StatusCode.SuccessOK).send(withoutInstances); - }, -); - -shopRouter.post( - "/item", - specification({ - method: "post", - path: "/shop/item/", - tag: Tag.SHOP, - role: Role.ADMIN, - summary: "Creates a shop item", - body: ShopItemCreateRequestSchema, - responses: { - [StatusCode.SuccessOK]: { - description: "The new item", - schema: ShopItemSchema, - }, - [StatusCode.ClientErrorConflict]: { - description: "The item already exists", - schema: ShopItemAlreadyExistsErrorSchema, - }, - }, - }), - async (req, res) => { - const details = req.body; - const itemId = "item" + parseInt(crypto.randomBytes(Config.SHOP_BYTES_GEN).toString("hex"), 16); - const instances = Array.from({ length: details.quantity }, (_, index) => getRand(index)); - - const shopItem: ShopItem = { - ...details, - itemId: itemId, - instances: instances, - }; - - // Ensure that item doesn't already exist before creating - const itemExists = (await Models.ShopItem.findOne({ name: details.name })) ?? false; - if (itemExists) { - return res.status(StatusCode.ClientErrorConflict).send(ShopItemAlreadyExistsError); - } - - const newItem = await Models.ShopItem.create(shopItem); - const withoutInstances = { - ...newItem.toObject(), - instances: undefined, - }; - - return res.status(StatusCode.SuccessOK).send(withoutInstances); + return res.status(StatusCode.SuccessOK).send(shopItems); }, ); @@ -203,82 +144,85 @@ shopRouter.post( schema: SuccessSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Order doesn't exist", - schema: ShopItemNotFoundErrorSchema, + description: "User's order doesn't exist in DB", + schema: ShopOrderNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", + schema: ShopInsufficientQuantityErrorSchema, + }, + [StatusCode.ClientErrorPaymentRequired]: { + description: "Not enough points to purchase", schema: ShopInsufficientFundsErrorSchema, }, }, }), async (req, res) => { - // when qr code is scanned, will call this so body needs to have order num and then i use that - // to get the order and then for each item in the order, subtract the quantity and then return success - const body = req.body; - const num = body.userId; - - const order = await Models.ShopOrder.findOne({ userId: num }); - - if (!order) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } + try { + const { userId } = req.body; - const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); - if (!profile) { - throw Error("Could not find attendee profile"); - } - - for (let i = 0; i < order.items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + // Retrieve the user's order + const order = await Models.ShopOrder.findOne({ userId }); + if (!order) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopOrderNotFoundError); + } - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + // Retrieve the user's profile + const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); + if (!profile) { + return res.status(StatusCode.ClientErrorNotFound).send(UserNotFoundError); } - const q = order.quantity?.[i] as number | 0; + let totalPointsRequired = 0; - if (q == undefined || item.quantity < q) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientFundsError); - } - } + // Loop through items and check availability and price + for (let i = 0; i < order.items.length; i++) { + const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } - for (let i = 0; i < order.items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + const quantity = order.quantity?.[i] ?? 0; // Default to 0 if undefined + totalPointsRequired += quantity * item.price; - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + // Check if requested quantity is available + if (quantity > item.quantity) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientQuantityError); + } } - const q = order.quantity?.[i] as number | 0; + // Check if the user has enough points for the order + const userProfile = await Models.AttendeeProfile.findOne({ userId: order.userId }); + if (!userProfile || userProfile.points < totalPointsRequired) { + return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); + } - /* - const updatedItem = await Models.ShopItem.findOneAndUpdate({ itemId: order.items[i] }, body, { - quantity: item.quantity - q, - }); - */ + // Update the inventory and user points + for (let i = 0; i < order.items.length; i++) { + const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + const quantity = order.quantity?.[i] ?? 0; + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } - const updatedShopQuantity = await Models.ShopItem.updateOne( - { itemId: order.items[i] }, - { - $inc: { quantity: -q }, - }, - ); + // Deduct item quantity from stock + await Models.ShopItem.updateOne({ itemId: order.items[i] }, { $inc: { quantity: -quantity } }); - if (!updatedShopQuantity) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + // Deduct points from user profile + await updatePoints(order.userId, -(quantity * item.price)); } - //update coins in user - await updatePoints(order.userId, -(q * item.price)).then(console.error); - } + // Clear the user's order from the cart + const result = await Models.ShopOrder.deleteOne({ userId }); + if (result.deletedCount === 0) { + return res.status(StatusCode.ClientErrorNotFound).json({ message: "Not able to clear cart" }); + } - const result = await Models.ShopOrder.deleteOne({ userId: num }); - if (result.deletedCount === 0) { - return res.status(StatusCode.ClientErrorNotFound).json({ message: "Not able to clear cart" }); + return res.status(StatusCode.SuccessOK).json({ message: "Success" }); + } catch (error) { + console.error("Error processing order:", error); + return res.status(StatusCode.ServerErrorInternal).json({ message: "Internal server error" }); } - - return res.status(StatusCode.SuccessOK).json({ message: "success" }); }, ); @@ -304,7 +248,7 @@ shopRouter.post( }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", - schema: ShopInsufficientFundsErrorSchema, + schema: ShopInsufficientQuantityErrorSchema, }, }, }), @@ -336,7 +280,7 @@ shopRouter.post( } if (item.quantity <= 0) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } //check if user has enough coins @@ -346,7 +290,7 @@ shopRouter.post( } if (profile.points < item.price) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } //add item to order or increase quantity @@ -401,10 +345,10 @@ shopRouter.get( schema: ShopItemGenerateOrderSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Item doesn't exist", - schema: ShopItemNotFoundErrorSchema, + description: "Order doesn't exist", + schema: ShopOrderNotFoundErrorSchema, }, - [StatusCode.ClientErrorBadRequest]: { + [StatusCode.ClientErrorPaymentRequired]: { description: "Not enough quantity in shop", schema: ShopInsufficientQuantityErrorSchema, }, @@ -427,29 +371,12 @@ shopRouter.get( } if (!userOrder) { - throw Error("Unable to view cart."); + return res.status(StatusCode.ClientErrorNotFound).send(ShopOrderNotFoundError); } const items = userOrder.items; const quantity = userOrder.quantity; - //check if enough quantity in shop - //Dont need to check - /* - for(let i = 0; i < items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: items[i] }); - - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } - - const q = quantity?.[i] as number | undefined; - if(q == undefined || item.quantity < q) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); - } - } - */ - return res.status(StatusCode.SuccessOK).send({ items: items, quantity: quantity }); }, ); @@ -482,7 +409,7 @@ shopRouter.get( const userOrder = await Models.ShopOrder.findOne({ userId: userId }); if (!userOrder) { - throw Error("no order"); + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } const items = userOrder.items; const quantity = userOrder.quantity; @@ -497,7 +424,7 @@ shopRouter.get( const q = quantity?.[i] as number | undefined; if (q == undefined || item.quantity < q) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } } @@ -517,7 +444,7 @@ shopRouter.get( } if (profile.points < currPrice) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } } @@ -528,9 +455,4 @@ shopRouter.get( }, ); -function getRand(index: number): string { - const hash = crypto.createHash("sha256").update(`${Config.JWT_SECRET}|${index}`).digest("hex"); - return hash; -} - export default shopRouter; diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index a953c483..6ff1e9db 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -21,36 +21,21 @@ export class ShopItem { @prop({ required: true }) public quantity: number; - @prop({ - required: true, - type: () => String, - }) - public instances: string[]; - - constructor( - itemId: string, - name: string, - price: number, - isRaffle: boolean, - imageURL: string, - quantity: number, - instances: string[], - ) { + constructor(itemId: string, name: string, price: number, isRaffle: boolean, imageURL: string, quantity: number) { this.itemId = itemId; this.name = name; this.price = price; this.isRaffle = isRaffle; this.imageURL = imageURL; this.quantity = quantity; - this.instances = instances; } } export class ShopOrder { - @prop({ required: true }) + @prop({ type: [String], required: true }) public items: Array; - @prop({ required: true }) + @prop({ type: [Number], required: true }) public quantity: Array; @prop({ required: true }) @@ -180,3 +165,8 @@ export const [ShopInsufficientQuantityError, ShopInsufficientQuantityErrorSchema error: "InsufficientFunds", message: "You don't have enough to purchase that item!", }); + +export const [ShopOrderNotFoundError, ShopOrderNotFoundErrorSchema] = CreateErrorAndSchema({ + error: "NotFound", + message: "Failed to user's order", +}); From e9e81f54233f102f5e33a06c9310ed246e0c2353 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Thu, 30 Jan 2025 01:31:08 -0600 Subject: [PATCH 09/15] better error handling/naming, tests, and return values --- src/common/config.ts | 2 + src/services/shop/shop-router.test.ts | 98 +++++++- src/services/shop/shop-router.ts | 333 +++++++++++++------------- src/services/shop/shop-schemas.ts | 27 +-- 4 files changed, 265 insertions(+), 195 deletions(-) diff --git a/src/common/config.ts b/src/common/config.ts index 33b71a52..d6a4a8a6 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -160,6 +160,8 @@ const Config = { QR_BYTES_KEY: 32, QR_IV: "3a7f4b8c1d2e5f6a9b0c8d7e6f5a4b3c", + NOT_FOUND: -1, + SHOP_ID_LENGTH: 2 * 2, EVENT_ID_LENGTH: 2 * 16, MAX_SHOP_STOCK_PER_ITEM: 128, diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts index a967fc37..ac5cec73 100644 --- a/src/services/shop/shop-router.test.ts +++ b/src/services/shop/shop-router.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "@jest/globals"; -import { getAsAttendee, postAsAttendee, postAsStaff, TESTER } from "../../common/testTools"; +import { delAsAttendee, getAsAttendee, postAsAttendee, postAsStaff, TESTER } from "../../common/testTools"; import { StatusCode } from "status-code-enum"; import Models from "../../common/models"; import { ShopItem, ShopOrder } from "./shop-schemas"; @@ -48,9 +48,7 @@ describe("POST /shop/cart/redeem", () => { .send({ userId: TESTER_PROFILE.userId }) .expect(StatusCode.SuccessOK); - expect(JSON.parse(response.text)).toMatchObject({ - message: "Success", - }); + expect(JSON.parse(response.text)).toMatchObject(TESTER_SHOP_ORDER); // Verify inventory was updated const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); @@ -66,14 +64,14 @@ describe("POST /shop/cart/redeem", () => { }); it("returns NotFound for non-existent order", async () => { - await postAsStaff("/shop/cart/redeem").send({ userId: "non-existent-user" }).expect(StatusCode.ClientErrorNotFound); + await postAsStaff("/shop/cart/redeem").send({ userId: "non-existent-user" }).expect(StatusCode.ServerErrorInternal); }); it("returns NotFound for non-existent user profile", async () => { // Create order but delete profile await Models.AttendeeProfile.deleteOne({ userId: TESTER_PROFILE.userId }); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ServerErrorInternal); }); it("returns NotFound for non-existent shop item", async () => { @@ -90,7 +88,7 @@ describe("POST /shop/cart/redeem", () => { { quantity: [11] }, // TESTER_SHOP_ITEM presumably has 10 quantity ); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorBadRequest); }); it("returns PaymentRequired for insufficient points", async () => { @@ -151,7 +149,8 @@ describe("POST /shop/cart/:itemId", () => { const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - message: "success", + ...TESTER_SHOP_ORDER, + quantity: [3], }); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); @@ -167,7 +166,8 @@ describe("POST /shop/cart/:itemId", () => { const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - message: "success", + ...TESTER_SHOP_ORDER, + quantity: [4], }); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); @@ -213,6 +213,78 @@ describe("POST /shop/cart/:itemId", () => { }); }); +describe("DELETE /shop/cart/:itemId", () => { + it("allows user to remove an item from the cart", async () => { + // Add item to the cart first + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + // Remove the item + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); + }); + + it("decreases the quantity of an item in the cart", async () => { + // Add the item to the cart twice + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + // Remove the item once + const response = await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + expect(JSON.parse(response.text)).toMatchObject({ + ...TESTER_SHOP_ORDER, + quantity: [1], + }); + + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); + expect(updatedOrder?.quantity[0]).toBe(1); // Item quantity should now be 1 + }); + + it("removes the item completely if the quantity reaches 0", async () => { + // Add the item to the cart twice + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + // Remove the item twice + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); // The item should be completely removed + }); + + it("returns NotFound for non-existent item in cart", async () => { + await delAsAttendee("/shop/cart/non-existent-item").expect(StatusCode.ClientErrorNotFound); + }); + + it("returns InternalServerError when no cart exists for the user", async () => { + // Delete the user's cart if exists + await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); + + // Try removing an item from a non-existent cart + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.ServerErrorInternal); + }); + + it("removes the item completely when quantity reaches 0 in a fresh cart", async () => { + // Start fresh with no cart + await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); + + // Add item to the cart + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + // Now delete the item + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + + const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); // Cart should be empty + }); +}); + describe("GET /shop/cart", () => { it("returns user's cart contents", async () => { const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); @@ -259,13 +331,13 @@ describe("GET /shop/cart/qr", () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: `hackillinois://userId?userId=${TESTER_PROFILE.userId}`, + qrInfo: `hackillinois://shop?userId=${TESTER_PROFILE.userId}`, }); }); - it("returns NotFound for non-existent cart", async () => { + it("returns InternalServerError for non-existent cart", async () => { await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorNotFound); + await getAsAttendee("/shop/cart/qr").expect(StatusCode.ServerErrorInternal); }); it("returns NotFound when cart item no longer exists in shop", async () => { @@ -353,7 +425,7 @@ describe("GET /shop/cart/qr", () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: `hackillinois://userId?userId=${TESTER_PROFILE.userId}`, + qrInfo: `hackillinois://shop?userId=${TESTER_PROFILE.userId}`, }); }); }); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index d60f9075..2fd830c8 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -6,17 +6,14 @@ import { ShopItemIdSchema, ShopItemNotFoundError, ShopItemNotFoundErrorSchema, - ShopItemSchema, ShopItemsSchema, - ShopItemUpdateRequestSchema, - ShopItemGenerateOrderSchema, ShopItemFulfillOrderSchema, - SuccessSchema, ShopOrder, OrderQRCodesSchema, ShopInsufficientQuantityError, - ShopOrderNotFoundError, - ShopOrderNotFoundErrorSchema, + ShopInternalErrorSchema, + ShopInternalError, + ShopOrderInfoSchema, } from "./shop-schemas"; import { Router } from "express"; import { StatusCode } from "status-code-enum"; @@ -24,10 +21,9 @@ import Models from "../../common/models"; import { Role } from "../auth/auth-schemas"; import specification, { Tag } from "../../middleware/specification"; import { z } from "zod"; -import { SuccessResponseSchema } from "../../common/schemas"; import { updatePoints } from "../profile/profile-lib"; import { getAuthenticatedUser } from "../../common/auth"; -import { UserNotFoundError } from "../user/user-schemas"; +import Config from "../../common/config"; const shopRouter = Router(); shopRouter.get( @@ -51,84 +47,6 @@ shopRouter.get( }, ); -shopRouter.put( - "/item/:id/", - specification({ - method: "put", - path: "/shop/item/{id}/", - tag: Tag.SHOP, - role: Role.ADMIN, - summary: "Updates a shop item", - parameters: z.object({ - id: ShopItemIdSchema, - }), - body: ShopItemUpdateRequestSchema, - responses: { - [StatusCode.SuccessOK]: { - description: "The new item", - schema: ShopItemSchema, - }, - [StatusCode.ClientErrorNotFound]: { - description: "Item doesn't exist", - schema: ShopItemNotFoundErrorSchema, - }, - }, - }), - async (req, res) => { - const { id: itemId } = req.params; - const updateRequest = req.body; - - const updatedItem = await Models.ShopItem.findOneAndUpdate({ itemId }, updateRequest, { - new: true, - }); - - if (!updatedItem) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } - - const withoutInstances = { - ...updatedItem.toObject(), - instances: undefined, - }; - - return res.status(StatusCode.SuccessOK).send(withoutInstances); - }, -); - -shopRouter.delete( - "/item/:id/", - specification({ - method: "delete", - path: "/shop/item/{id}/", - tag: Tag.SHOP, - role: Role.ADMIN, - summary: "Deletes a shop item", - parameters: z.object({ - id: ShopItemIdSchema, - }), - responses: { - [StatusCode.SuccessOK]: { - description: "Successfully deleted", - schema: SuccessResponseSchema, - }, - [StatusCode.ClientErrorNotFound]: { - description: "Item doesn't exist", - schema: ShopItemNotFoundErrorSchema, - }, - }, - }), - async (req, res) => { - const { id: itemId } = req.params; - const deleted = await Models.ShopItem.deleteOne({ itemId }); - - if (deleted.deletedCount == 0) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } - - return res.status(StatusCode.SuccessOK).send({ success: true }); - }, -); - shopRouter.post( "/cart/redeem", specification({ @@ -141,11 +59,11 @@ shopRouter.post( responses: { [StatusCode.SuccessOK]: { description: "The successfully purchased order", - schema: SuccessSchema, + schema: ShopOrderInfoSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "User's order doesn't exist in DB", - schema: ShopOrderNotFoundErrorSchema, + description: "Shop Item DNE", + schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", @@ -155,6 +73,10 @@ shopRouter.post( description: "Not enough points to purchase", schema: ShopInsufficientFundsErrorSchema, }, + [StatusCode.ServerErrorInternal]: { + description: "Errors that should never happen", + schema: ShopInternalErrorSchema, + }, }, }), async (req, res) => { @@ -164,13 +86,13 @@ shopRouter.post( // Retrieve the user's order const order = await Models.ShopOrder.findOne({ userId }); if (!order) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopOrderNotFoundError); + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } // Retrieve the user's profile const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); if (!profile) { - return res.status(StatusCode.ClientErrorNotFound).send(UserNotFoundError); + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } let totalPointsRequired = 0; @@ -187,13 +109,12 @@ shopRouter.post( // Check if requested quantity is available if (quantity > item.quantity) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopInsufficientQuantityError); + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } } // Check if the user has enough points for the order - const userProfile = await Models.AttendeeProfile.findOne({ userId: order.userId }); - if (!userProfile || userProfile.points < totalPointsRequired) { + if (!profile || profile.points < totalPointsRequired) { return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } @@ -213,15 +134,11 @@ shopRouter.post( } // Clear the user's order from the cart - const result = await Models.ShopOrder.deleteOne({ userId }); - if (result.deletedCount === 0) { - return res.status(StatusCode.ClientErrorNotFound).json({ message: "Not able to clear cart" }); - } - - return res.status(StatusCode.SuccessOK).json({ message: "Success" }); + await Models.ShopOrder.deleteOne({ userId }); + return res.status(StatusCode.SuccessOK).json(order); } catch (error) { console.error("Error processing order:", error); - return res.status(StatusCode.ServerErrorInternal).json({ message: "Internal server error" }); + return res.status(StatusCode.ServerErrorInternal).json(ShopInternalError); } }, ); @@ -239,17 +156,25 @@ shopRouter.post( }), responses: { [StatusCode.SuccessOK]: { - description: "The qr codes", - schema: SuccessSchema, + description: "The successfully updated order", + schema: ShopOrderInfoSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Item doesn't exist", + description: "Shop Item DNE", schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", schema: ShopInsufficientQuantityErrorSchema, }, + [StatusCode.ClientErrorPaymentRequired]: { + description: "Not enough points to purchase", + schema: ShopInsufficientFundsErrorSchema, + }, + [StatusCode.ServerErrorInternal]: { + description: "Errors that should never happen", + schema: ShopInternalErrorSchema, + }, }, }), async (req, res) => { @@ -265,12 +190,12 @@ shopRouter.post( userId: userId, }; - await Models.ShopOrder.create(shopOrder); - userOrder = await Models.ShopOrder.findOne({ userId: userId }); + userOrder = await Models.ShopOrder.create(shopOrder); } + // This should never get hit, just checking here so typescript doesn't get mad if (!userOrder) { - throw Error("Creating cart for user failed."); + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } //check if enough quantity in shop @@ -285,8 +210,9 @@ shopRouter.post( //check if user has enough coins const profile = await Models.AttendeeProfile.findOne({ userId: userId }); + // This should never get hit. if (!profile) { - throw Error("Could not find attendee profile"); + return res.status(StatusCode.ServerErrorInternal).json(ShopInternalError); } if (profile.points < item.price) { @@ -297,22 +223,22 @@ shopRouter.post( const items = userOrder.items; let found = false; for (let i = 0; i < items.length; i++) { - if ((items[i] = itemId)) { + if (items[i] === itemId) { found = true; - const updatedShopOrder = await Models.ShopOrder.updateOne( + const updatedOrder = await Models.ShopOrder.updateOne( { userId: userId }, { $inc: { [`quantity.${i}`]: 1 }, }, ); - if (!updatedShopOrder) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + if (!updatedOrder) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } } } if (!found) { - const updatedShopOrder = await Models.ShopOrder.updateOne( + const updatedOrder = await Models.ShopOrder.updateOne( { userId: userId }, { $push: { @@ -322,12 +248,93 @@ shopRouter.post( }, ); - if (!updatedShopOrder) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + if (!updatedOrder) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } } - return res.status(StatusCode.SuccessOK).send({ message: "success" }); + const updatedOrder = await Models.ShopOrder.findOne({ userId }); + if (updatedOrder) { + return res.status(StatusCode.SuccessOK).json(updatedOrder); + } + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + }, +); + +shopRouter.delete( + "/cart/:itemId", + specification({ + method: "delete", + path: "/shop/cart/{itemId}/", + tag: Tag.SHOP, + role: Role.USER, + summary: "Removes a single instance of an item from the user's cart", + parameters: z.object({ + itemId: ShopItemIdSchema, + }), + responses: { + [StatusCode.SuccessOK]: { + description: "The successfully updated order", + schema: ShopOrderInfoSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Shop Item DNE", + schema: ShopItemNotFoundErrorSchema, + }, + [StatusCode.ServerErrorInternal]: { + description: "Errors that should never happen", + schema: ShopInternalErrorSchema, + }, + }, + }), + async (req, res) => { + const { itemId } = req.params; + const { id: userId } = getAuthenticatedUser(req); + + const userOrder = await Models.ShopOrder.findOne({ userId: userId }); + + // Check if user has an order + if (!userOrder) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); // No order found + } + + // Find the index of the item in the user's cart + const itemIndex = userOrder.items.indexOf(itemId); + if (itemIndex === Config.NOT_FOUND) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); // Item not in cart + } + + // Update the order, decrement the quantity of the item by 1 + const updatedShopOrder = await Models.ShopOrder.updateOne( + { userId: userId }, + { + $inc: { [`quantity.${itemIndex}`]: -1 }, + }, + ); + + // If update fails + if (!updatedShopOrder) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); // Internal error + } + + // If the quantity of the item becomes 0, remove the item from the cart + if ((userOrder.quantity?.[itemIndex] ?? 0) - 1 === 0) { + await Models.ShopOrder.updateOne( + { userId: userId }, + { + $pull: { + items: itemId, + quantity: 0, // Remove the corresponding quantity as well + }, + }, + ); + } + + const updatedOrder = await Models.ShopOrder.findOne({ userId }); + if (updatedOrder) { + return res.status(StatusCode.SuccessOK).json(updatedOrder); + } + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); }, ); @@ -342,15 +349,11 @@ shopRouter.get( responses: { [StatusCode.SuccessOK]: { description: "List of items and quantity", - schema: ShopItemGenerateOrderSchema, + schema: ShopOrderInfoSchema, }, - [StatusCode.ClientErrorNotFound]: { - description: "Order doesn't exist", - schema: ShopOrderNotFoundErrorSchema, - }, - [StatusCode.ClientErrorPaymentRequired]: { - description: "Not enough quantity in shop", - schema: ShopInsufficientQuantityErrorSchema, + [StatusCode.ServerErrorInternal]: { + description: "Errors that should never happen", + schema: ShopInternalErrorSchema, }, }, }), @@ -366,18 +369,14 @@ shopRouter.get( userId: userId, }; - await Models.ShopOrder.create(shopOrder); - userOrder = await Models.ShopOrder.findOne({ userId: userId }); + userOrder = await Models.ShopOrder.create(shopOrder); } if (!userOrder) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopOrderNotFoundError); + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - const items = userOrder.items; - const quantity = userOrder.quantity; - - return res.status(StatusCode.SuccessOK).send({ items: items, quantity: quantity }); + return res.status(StatusCode.SuccessOK).send(userOrder); }, ); @@ -391,66 +390,74 @@ shopRouter.get( summary: "Returns qr code of users cart", responses: { [StatusCode.SuccessOK]: { - description: "The qr codes", + description: "QR code", schema: OrderQRCodesSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Item doesn't exist", + description: "Shop Item DNE", schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", schema: ShopInsufficientFundsErrorSchema, }, + [StatusCode.ClientErrorPaymentRequired]: { + description: "User doesn't have enough points to purchase", + schema: ShopInsufficientFundsErrorSchema, + }, + [StatusCode.ServerErrorInternal]: { + description: "Errors that should never happen", + schema: ShopInternalErrorSchema, + }, }, }), async (req, res) => { const { id: userId } = getAuthenticatedUser(req); - const userOrder = await Models.ShopOrder.findOne({ userId: userId }); + // Fetch user order + const userOrder = await Models.ShopOrder.findOne({ userId }); if (!userOrder) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - const items = userOrder.items; - const quantity = userOrder.quantity; - //check if enough quantity in shop - for (let i = 0; i < items.length; i++) { - //items[i] is the _id of the items - const item = await Models.ShopItem.findOne({ itemId: items[i] }); - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } + const { items, quantity } = userOrder; - const q = quantity?.[i] as number | undefined; - if (q == undefined || item.quantity < q) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); - } - } + // Fetch all shop items in one query + const shopItems = await Models.ShopItem.find({ itemId: { $in: items } }); + const itemMap = new Map(shopItems.map((item) => [item.itemId, item])); - //check if user has enough coins - let currPrice = 0; + // Validate item availability for (let i = 0; i < items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: items[i] }); + const item = itemMap.get(items[i] ?? ""); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - - currPrice += item.price; - - const profile = await Models.AttendeeProfile.findOne({ userId: userId }); - if (!profile) { - throw Error("Could not find attendee profile"); + const currentQuantity = quantity[i] ?? 0; + if (currentQuantity > item.quantity) { + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); } + } - if (profile.points < currPrice) { - return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); - } + // Fetch user profile once + const profile = await Models.AttendeeProfile.findOne({ userId }); + if (!profile) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - //have availability of all item and user has enough coins so can generate qr code with order number - const qrCodeUrl = `hackillinois://userId?userId=${userId}`; + // Compute total cost + const totalPrice = items.reduce((sum, itemId, i) => { + const itemPrice = itemMap.get(itemId)?.price ?? 0; // Default to 0 if item price is not found + const itemQuantity = quantity[i] ?? 0; // Default to 0 if quantity is undefined + return sum + itemPrice * itemQuantity; + }, 0); + + // Check if user has enough points + if (profile.points < totalPrice) { + return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); + } + // Generate QR code + const qrCodeUrl = `hackillinois://shop?userId=${userId}`; return res.status(StatusCode.SuccessOK).send({ qrInfo: qrCodeUrl }); }, ); diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 6ff1e9db..a302a065 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -104,26 +104,15 @@ export const ShopItemUpdateRequestSchema = ShopItemSchema.omit({ itemId: true }) }, }); -export const ShopItemQRCodeSchema = z.string().openapi("ShopItemQRCode", { - example: "hackillinois://item?itemId=item1234&instance=1x3", -}); - -export const ShopItemQRCodesSchema = z - .object({ - itemId: ShopItemIdSchema, - qrInfo: z.array(ShopItemQRCodeSchema), - }) - .openapi("ShopItemQRCodes"); - export const ShopItemBuyRequestSchema = z.object({ itemId: ShopItemIdSchema, instance: z.string().openapi({ example: "1x3" }), }); -// needs to have list of items and quantity -export const ShopItemGenerateOrderSchema = z.object({ +export const ShopOrderInfoSchema = z.object({ items: z.array(z.string()), quantity: z.array(z.number()), + userId: z.string(), }); export const ShopItemFulfillOrderSchema = z.object({ @@ -131,7 +120,7 @@ export const ShopItemFulfillOrderSchema = z.object({ }); export const OrderQRCodeSchema = z.string().openapi("OrderQRCode", { - example: "hackillinois://ordernum?orderNum=10", + example: "hackillinois://shop?userId=github1203919029", }); export const OrderQRCodesSchema = z @@ -162,11 +151,11 @@ export const [ShopInsufficientFundsError, ShopInsufficientFundsErrorSchema] = Cr }); export const [ShopInsufficientQuantityError, ShopInsufficientQuantityErrorSchema] = CreateErrorAndSchema({ - error: "InsufficientFunds", - message: "You don't have enough to purchase that item!", + error: "InsufficientQuantity", + message: "Not enough of that item in the shop/your cart", }); -export const [ShopOrderNotFoundError, ShopOrderNotFoundErrorSchema] = CreateErrorAndSchema({ - error: "NotFound", - message: "Failed to user's order", +export const [ShopInternalError, ShopInternalErrorSchema] = CreateErrorAndSchema({ + error: "InternalError", + message: "This should never happen. i.e. user without attendeeProfile, user without shopOrder, etc.", }); From dd2aab5255796e9b3f210926c75b64e5e26cfe03 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 12:05:45 -0600 Subject: [PATCH 10/15] findOne -> findAll, updated shopOrder to use a map --- src/services/shop/shop-router.test.ts | 207 ++++++++++++++------------ src/services/shop/shop-router.ts | 163 ++++++++++---------- src/services/shop/shop-schemas.ts | 39 ++--- 3 files changed, 205 insertions(+), 204 deletions(-) diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts index ac5cec73..71b2029d 100644 --- a/src/services/shop/shop-router.test.ts +++ b/src/services/shop/shop-router.test.ts @@ -5,6 +5,7 @@ import Models from "../../common/models"; import { ShopItem, ShopOrder } from "./shop-schemas"; import { AttendeeProfile } from "../profile/profile-schemas"; +// Define test item const TESTER_SHOP_ITEM = { itemId: "test-item-1", name: "Test Item", @@ -14,12 +15,9 @@ const TESTER_SHOP_ITEM = { quantity: 10, } satisfies ShopItem; -const TESTER_SHOP_ORDER = { - userId: TESTER.id, - items: ["test-item-1"], - quantity: [2], -} satisfies ShopOrder; +const TESTER_SHOP_ORDER = new ShopOrder([["test-item-1", 2]], TESTER.id) satisfies ShopOrder; +// Define test profile const TESTER_PROFILE = { userId: TESTER.id, displayName: TESTER.name, @@ -42,19 +40,26 @@ beforeEach(async () => { await Models.AttendeeProfile.create(TESTER_PROFILE); }); +// +// POST /shop/cart/redeem +// describe("POST /shop/cart/redeem", () => { it("allows staff to successfully redeem an order", async () => { const response = await postAsStaff("/shop/cart/redeem") .send({ userId: TESTER_PROFILE.userId }) .expect(StatusCode.SuccessOK); - expect(JSON.parse(response.text)).toMatchObject(TESTER_SHOP_ORDER); + // Expect returned order to use the new tuple format. + expect(JSON.parse(response.text)).toMatchObject({ + userId: TESTER_PROFILE.userId, + items: [["test-item-1", 2]], + }); - // Verify inventory was updated + // Verify inventory was updated: quantity reduced by 2 (from 10 to 8) const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); expect(updatedItem?.quantity).toBe(8); - // Verify points were deducted + // Verify points were deducted (2 * 100 = 200, so 1000-200 = 800) const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); expect(updatedProfile?.points).toBe(800); @@ -75,24 +80,21 @@ describe("POST /shop/cart/redeem", () => { }); it("returns NotFound for non-existent shop item", async () => { - // Create order with non-existent item - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["non-existent-item"], quantity: [1] }); + // Update order so that it now references a non-existent item. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); }); it("returns BadRequest for insufficient item quantity", async () => { - // Update order to request more items than available - await Models.ShopOrder.updateOne( - { userId: TESTER_PROFILE.userId }, - { quantity: [11] }, // TESTER_SHOP_ITEM presumably has 10 quantity - ); + // Request more than available (11 instead of available 10) + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 11]] }); await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorBadRequest); }); it("returns PaymentRequired for insufficient points", async () => { - // Update profile to have insufficient points + // Set the user’s points to 0 await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 0 }); await postAsStaff("/shop/cart/redeem") @@ -101,18 +103,20 @@ describe("POST /shop/cart/redeem", () => { }); it("handles undefined quantity correctly", async () => { - // Create order with undefined quantity - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { $unset: { quantity: 1 } }); + // Unset the quantity for "test-item-1" in the items map. + // (Assuming the ShopOrder is stored as an object in MongoDB, + // unsetting the field will cause its quantity to be undefined.) + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { $unset: { "items.test-item-1": "" } }); await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); - // Should treat undefined quantity as 0 + // Since undefined should be treated as 0, the shop item’s quantity should remain unchanged. const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); - expect(updatedItem?.quantity).toBe(TESTER_SHOP_ITEM.quantity); // Quantity shouldn't change + expect(updatedItem?.quantity).toBe(TESTER_SHOP_ITEM.quantity); }); it("handles multiple items in order correctly", async () => { - // Create second test item + // Create a second test item. const secondItem = { ...TESTER_SHOP_ITEM, itemId: "test-item-2", @@ -120,59 +124,65 @@ describe("POST /shop/cart/redeem", () => { }; await Models.ShopItem.create(secondItem); - // Update order to include multiple items + // Update order to include two items: + // "test-item-1": 1 unit and "test-item-2": 2 units. await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { - items: [TESTER_SHOP_ITEM.itemId, secondItem.itemId], - quantity: [1, 2], + items: [ + ["test-item-1", 1], + [secondItem.itemId, 2], + ], }, ); await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); - // Verify all items were updated correctly + // Verify inventory updates. const updatedItem1 = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); const updatedItem2 = await Models.ShopItem.findOne({ itemId: secondItem.itemId }); expect(updatedItem1?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 1); expect(updatedItem2?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 2); - // Verify total points deduction (TESTER_SHOP_ITEM.price * 1 + secondItem.price * 2) + // Verify total points deduction: (100 * 1) + (50 * 2) const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); - const expectedPoints = TESTER_PROFILE.points - (TESTER_SHOP_ITEM.price + secondItem.price * 2); + const expectedPoints = TESTER_PROFILE.points - (TESTER_SHOP_ITEM.price * 1 + secondItem.price * 2); expect(updatedProfile?.points).toBe(expectedPoints); }); }); +// +// POST /shop/cart/:itemId +// describe("POST /shop/cart/:itemId", () => { it("allows user to add new item to cart", async () => { const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + // Since the initial order had 2 units, adding one more should yield 3. expect(JSON.parse(response.text)).toMatchObject({ - ...TESTER_SHOP_ORDER, - quantity: [3], + userId: TESTER_PROFILE.userId, + items: [["test-item-1", 3]], }); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); - expect(updatedOrder?.quantity[0]).toBe(3); + expect(updatedOrder?.items.get("test-item-1")).toBe(3); }); it("increases quantity when adding existing item to cart", async () => { - // First addition + // First addition. await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - // Second addition + // Second addition. const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + // Starting with 2, two additions should result in 4. expect(JSON.parse(response.text)).toMatchObject({ - ...TESTER_SHOP_ORDER, - quantity: [4], + userId: TESTER_PROFILE.userId, + items: [["test-item-1", 4]], }); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); - expect(updatedOrder?.quantity[0]).toBe(4); + expect(updatedOrder?.items.get("test-item-1")).toBe(4); }); it("returns NotFound for non-existent item", async () => { @@ -201,118 +211,123 @@ describe("POST /shop/cart/:itemId", () => { }); it("creates new cart if user doesn't have one", async () => { - // Delete any existing cart + // Delete any existing cart. await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); const newOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); expect(newOrder).toBeTruthy(); - expect(newOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); - expect(newOrder?.quantity[0]).toBe(1); + // For a new cart, the item should be added with quantity 1. + expect(newOrder?.items.get("test-item-1")).toBe(1); }); }); +// +// DELETE /shop/cart/:itemId +// describe("DELETE /shop/cart/:itemId", () => { it("allows user to remove an item from the cart", async () => { - // Add item to the cart first - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + // Set the cart so that test-item-1 has quantity 1. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 1]] }); - // Remove the item + // Removing the item when its quantity is 1 should remove it completely. await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); + expect(updatedOrder?.items.has("test-item-1")).toBe(false); }); it("decreases the quantity of an item in the cart", async () => { - // Add the item to the cart twice - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + // Start with quantity 0 for the item. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 0]] }); + // Add the item twice. + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); // becomes 1 + await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); // becomes 2 - // Remove the item once + // Remove the item once. const response = await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - ...TESTER_SHOP_ORDER, - quantity: [1], + userId: TESTER_PROFILE.userId, + items: [["test-item-1", 1]], }); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).toContain(TESTER_SHOP_ITEM.itemId); - expect(updatedOrder?.quantity[0]).toBe(1); // Item quantity should now be 1 + expect(updatedOrder?.items.get("test-item-1")).toBe(1); }); it("removes the item completely if the quantity reaches 0", async () => { - // Add the item to the cart twice - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["test-item-1"], quantity: [0] }); + // Start with quantity 0. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 0]] }); + // Add the item twice. await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - // Remove the item twice + // Remove the item twice. await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); // The item should be completely removed + expect(updatedOrder?.items.has("test-item-1")).toBe(false); }); it("returns NotFound for non-existent item in cart", async () => { await delAsAttendee("/shop/cart/non-existent-item").expect(StatusCode.ClientErrorNotFound); }); - it("returns InternalServerError when no cart exists for the user", async () => { - // Delete the user's cart if exists + it("returns NotFound when no cart exists for the user", async () => { await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - - // Try removing an item from a non-existent cart - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.ServerErrorInternal); + await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.ClientErrorNotFound); }); it("removes the item completely when quantity reaches 0 in a fresh cart", async () => { - // Start fresh with no cart + // Start fresh with no cart. await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - // Add item to the cart + // Add an item. await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - // Now delete the item + // Now delete the item. await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items).not.toContain(TESTER_SHOP_ITEM.itemId); // Cart should be empty + expect(updatedOrder?.items.has("test-item-1")).toBe(false); }); }); +// +// GET /shop/cart +// describe("GET /shop/cart", () => { it("returns user's cart contents", async () => { const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - items: [TESTER_SHOP_ITEM.itemId], - quantity: [2], + userId: TESTER_PROFILE.userId, + items: [["test-item-1", 2]], }); }); it("creates and returns empty cart for new user", async () => { - // Ensure no existing cart + // Ensure no existing cart. await Models.ShopOrder.findOneAndDelete({ userId: TESTER.id }); const response = await getAsAttendee("/shop/cart").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ + userId: TESTER.id, items: [], - quantity: [], }); }); - it("returns cart with matching items and quantity arrays", async () => { + it("returns cart with matching items and quantities", async () => { await Models.ShopOrder.findOneAndDelete({ userId: TESTER.id }); const shopOrder = { userId: TESTER.id, - items: [TESTER_SHOP_ITEM.itemId, "test-item-2"], - quantity: [1, 3], + items: [ + [TESTER_SHOP_ITEM.itemId, 1], + ["test-item-2", 3], + ], }; await Models.ShopOrder.create(shopOrder); @@ -320,12 +335,18 @@ describe("GET /shop/cart", () => { const cart = JSON.parse(response.text); expect(cart).toMatchObject({ - items: [TESTER_SHOP_ITEM.itemId, "test-item-2"], - quantity: [1, 3], + userId: TESTER.id, + items: [ + [TESTER_SHOP_ITEM.itemId, 1], + ["test-item-2", 3], + ], }); }); }); +// +// GET /shop/cart/qr +// describe("GET /shop/cart/qr", () => { it("returns QR code URL for valid cart", async () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); @@ -341,18 +362,17 @@ describe("GET /shop/cart/qr", () => { }); it("returns NotFound when cart item no longer exists in shop", async () => { - // Create cart with non-existent item - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["non-existent-item"], quantity: [1] }); + // Create a cart with an item that does not exist. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorNotFound); - expect(JSON.parse(response.text)).toMatchObject({ error: expect.any(String), }); }); it("returns BadRequest when insufficient shop quantity", async () => { - // Create item with low quantity + // Create an item with a low quantity. const lowQuantityItem = { ...TESTER_SHOP_ITEM, itemId: "low-quantity-item", @@ -360,18 +380,17 @@ describe("GET /shop/cart/qr", () => { }; await Models.ShopItem.create(lowQuantityItem); - // Create cart requesting more than available - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["low-quantity-item"], quantity: [2] }); + // Create a cart requesting more than available. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["low-quantity-item", 2]] }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorBadRequest); - expect(JSON.parse(response.text)).toMatchObject({ error: expect.any(String), }); }); it("returns PaymentRequired when insufficient points for total cart", async () => { - // Create expensive items + // Create expensive items. const expensiveItem1 = { ...TESTER_SHOP_ITEM, itemId: "expensive-item-1", @@ -387,27 +406,28 @@ describe("GET /shop/cart/qr", () => { await Models.ShopItem.create(expensiveItem1); await Models.ShopItem.create(expensiveItem2); - // Create cart with multiple expensive items + // Create cart with the expensive items. await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { - items: ["expensive-item-1", "expensive-item-2"], - quantity: [1, 1], + items: [ + ["expensive-item-1", 1], + ["expensive-item-2", 1], + ], }, ); - // Set user points to less than total cart value + // Set user points to less than the total price. await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 1000 }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorPaymentRequired); - expect(JSON.parse(response.text)).toMatchObject({ error: expect.any(String), }); }); it("succeeds when user has exactly enough points", async () => { - // Create item with known price + // Create an item with a known price. const item = { ...TESTER_SHOP_ITEM, itemId: "exact-price-item", @@ -416,14 +436,13 @@ describe("GET /shop/cart/qr", () => { }; await Models.ShopItem.create(item); - // Create cart with the item - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: ["exact-price-item"], quantity: [1] }); + // Create a cart with this item. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["exact-price-item", 1]] }); - // Set user points to exact amount needed + // Set the user's points to the exact amount needed. await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 100 }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); - expect(JSON.parse(response.text)).toMatchObject({ qrInfo: `hackillinois://shop?userId=${TESTER_PROFILE.userId}`, }); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 2fd830c8..12cd9896 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -23,7 +23,6 @@ import specification, { Tag } from "../../middleware/specification"; import { z } from "zod"; import { updatePoints } from "../profile/profile-lib"; import { getAuthenticatedUser } from "../../common/auth"; -import Config from "../../common/config"; const shopRouter = Router(); shopRouter.get( @@ -95,47 +94,61 @@ shopRouter.post( return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } + // Gather all item IDs from the order + const itemIds = Array.from(order.items.keys()); + + // Fetch all shop items with one query + const items = await Models.ShopItem.find({ itemId: { $in: itemIds } }); + + // Create a map of itemId to item document for easy lookup + const itemsMap = new Map(items.map((item) => [item.itemId, item])); + let totalPointsRequired = 0; - // Loop through items and check availability and price - for (let i = 0; i < order.items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); + // Loop through each order item to check availability and calculate total points required + for (const [itemId, quantity] of order.items.entries()) { + const item = itemsMap.get(itemId); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - const quantity = order.quantity?.[i] ?? 0; // Default to 0 if undefined totalPointsRequired += quantity * item.price; - // Check if requested quantity is available + // Check if the requested quantity is available if (quantity > item.quantity) { return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } } // Check if the user has enough points for the order - if (!profile || profile.points < totalPointsRequired) { + if (profile.points < totalPointsRequired) { return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } - // Update the inventory and user points - for (let i = 0; i < order.items.length; i++) { - const item = await Models.ShopItem.findOne({ itemId: order.items[i] }); - const quantity = order.quantity?.[i] ?? 0; + // Update the inventory and deduct points + for (const [itemId, quantity] of order.items.entries()) { + const item = itemsMap.get(itemId); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - // Deduct item quantity from stock - await Models.ShopItem.updateOne({ itemId: order.items[i] }, { $inc: { quantity: -quantity } }); + // Deduct the item's quantity from stock + await Models.ShopItem.updateOne({ itemId }, { $inc: { quantity: -quantity } }); - // Deduct points from user profile + // Deduct points from the user's profile await updatePoints(order.userId, -(quantity * item.price)); } // Clear the user's order from the cart await Models.ShopOrder.deleteOne({ userId }); - return res.status(StatusCode.SuccessOK).json(order); + + // Convert order.items (a Map) to an array of tuples since Zod doesn't support maps + const zodOrder = { + userId: order.userId, + items: Array.from(order.items), + }; + + return res.status(StatusCode.SuccessOK).json(zodOrder); } catch (error) { console.error("Error processing order:", error); return res.status(StatusCode.ServerErrorInternal).json(ShopInternalError); @@ -182,15 +195,10 @@ shopRouter.post( const { id: userId } = getAuthenticatedUser(req); let userOrder = await Models.ShopOrder.findOne({ userId: userId }); - //user doesn't have a order yet + // user doesn't have an order yet if (!userOrder) { - const shopOrder: ShopOrder = { - items: [], - quantity: [], - userId: userId, - }; - - userOrder = await Models.ShopOrder.create(shopOrder); + // Create a new order with an empty list of items (which becomes an empty Map) + userOrder = await Models.ShopOrder.create(new ShopOrder([], userId)); } // This should never get hit, just checking here so typescript doesn't get mad @@ -198,7 +206,7 @@ shopRouter.post( return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - //check if enough quantity in shop + // check if enough quantity in shop const item = await Models.ShopItem.findOne({ itemId: itemId }); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); @@ -208,7 +216,7 @@ shopRouter.post( return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } - //check if user has enough coins + // check if user has enough coins const profile = await Models.AttendeeProfile.findOne({ userId: userId }); // This should never get hit. if (!profile) { @@ -219,35 +227,25 @@ shopRouter.post( return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } - //add item to order or increase quantity - const items = userOrder.items; - let found = false; - for (let i = 0; i < items.length; i++) { - if (items[i] === itemId) { - found = true; - - const updatedOrder = await Models.ShopOrder.updateOne( - { userId: userId }, - { - $inc: { [`quantity.${i}`]: 1 }, - }, - ); - if (!updatedOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); - } + // add item to order or increase quantity + // Since userOrder.items is now a Map, check if the item already exists. + if (userOrder.items.has(itemId)) { + const updatedOrder = await Models.ShopOrder.updateOne( + { userId: userId }, + { + $inc: { [`items.${itemId}`]: 1 }, + }, + ); + if (!updatedOrder) { + return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - } - if (!found) { + } else { const updatedOrder = await Models.ShopOrder.updateOne( { userId: userId }, { - $push: { - items: itemId, - quantity: 1, - }, + $set: { [`items.${itemId}`]: 1 }, }, ); - if (!updatedOrder) { return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } @@ -255,7 +253,12 @@ shopRouter.post( const updatedOrder = await Models.ShopOrder.findOne({ userId }); if (updatedOrder) { - return res.status(StatusCode.SuccessOK).json(updatedOrder); + // Convert order to array of tuples cuz zod doesn't fw maps + const zodOrder = { + userId: updatedOrder.userId, + items: Array.from(updatedOrder.items), + }; + return res.status(StatusCode.SuccessOK).json(zodOrder); } return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); }, @@ -295,20 +298,19 @@ shopRouter.delete( // Check if user has an order if (!userOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); // No order found + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); // No order found } - // Find the index of the item in the user's cart - const itemIndex = userOrder.items.indexOf(itemId); - if (itemIndex === Config.NOT_FOUND) { + // Check if the item exists in the user's cart (map) + if (!userOrder.items.has(itemId)) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); // Item not in cart } - // Update the order, decrement the quantity of the item by 1 + // Decrement the quantity of the item by 1 const updatedShopOrder = await Models.ShopOrder.updateOne( { userId: userId }, { - $inc: { [`quantity.${itemIndex}`]: -1 }, + $inc: { [`items.${itemId}`]: -1 }, }, ); @@ -318,21 +320,23 @@ shopRouter.delete( } // If the quantity of the item becomes 0, remove the item from the cart - if ((userOrder.quantity?.[itemIndex] ?? 0) - 1 === 0) { + if ((userOrder.items.get(itemId) ?? 0) - 1 === 0) { await Models.ShopOrder.updateOne( { userId: userId }, { - $pull: { - items: itemId, - quantity: 0, // Remove the corresponding quantity as well - }, + $unset: { [`items.${itemId}`]: "" }, }, ); } const updatedOrder = await Models.ShopOrder.findOne({ userId }); if (updatedOrder) { - return res.status(StatusCode.SuccessOK).json(updatedOrder); + // Convert order to array of tuples cuz zod doesn't fw maps + const zodOrder = { + userId: updatedOrder.userId, + items: Array.from(updatedOrder.items), + }; + return res.status(StatusCode.SuccessOK).json(zodOrder); } return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); }, @@ -360,23 +364,22 @@ shopRouter.get( async (req, res) => { const { id: userId } = getAuthenticatedUser(req); - //get their order from order db + // Get their order from order db let userOrder = await Models.ShopOrder.findOne({ userId: userId }); if (!userOrder) { - const shopOrder: ShopOrder = { - items: [], - quantity: [], - userId: userId, - }; - - userOrder = await Models.ShopOrder.create(shopOrder); + userOrder = await Models.ShopOrder.create(new ShopOrder([], userId)); } if (!userOrder) { return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - return res.status(StatusCode.SuccessOK).send(userOrder); + // Convert order to array of tuples cuz zod doesn't fw maps + const zodOrder = { + userId: userOrder.userId, + items: Array.from(userOrder.items), + }; + return res.status(StatusCode.SuccessOK).send(zodOrder); }, ); @@ -420,19 +423,19 @@ shopRouter.get( return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); } - const { items, quantity } = userOrder; + // Get all item IDs from the order's map + const itemIds = Array.from(userOrder.items.keys()); // Fetch all shop items in one query - const shopItems = await Models.ShopItem.find({ itemId: { $in: items } }); + const shopItems = await Models.ShopItem.find({ itemId: { $in: itemIds } }); const itemMap = new Map(shopItems.map((item) => [item.itemId, item])); // Validate item availability - for (let i = 0; i < items.length; i++) { - const item = itemMap.get(items[i] ?? ""); + for (const [itemId, currentQuantity] of userOrder.items.entries()) { + const item = itemMap.get(itemId); if (!item) { return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } - const currentQuantity = quantity[i] ?? 0; if (currentQuantity > item.quantity) { return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); } @@ -445,11 +448,11 @@ shopRouter.get( } // Compute total cost - const totalPrice = items.reduce((sum, itemId, i) => { - const itemPrice = itemMap.get(itemId)?.price ?? 0; // Default to 0 if item price is not found - const itemQuantity = quantity[i] ?? 0; // Default to 0 if quantity is undefined - return sum + itemPrice * itemQuantity; - }, 0); + let totalPrice = 0; + for (const [itemId, currentQuantity] of userOrder.items.entries()) { + const itemPrice = itemMap.get(itemId)?.price ?? 0; + totalPrice += itemPrice * currentQuantity; + } // Check if user has enough points if (profile.points < totalPrice) { diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index a302a065..7d610cd1 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -1,4 +1,4 @@ -import { prop } from "@typegoose/typegoose"; +import { prop, modelOptions, Severity } from "@typegoose/typegoose"; import { z } from "zod"; import { CreateErrorAndSchema } from "../../common/schemas"; @@ -31,30 +31,21 @@ export class ShopItem { } } +@modelOptions({ options: { allowMixed: Severity.ALLOW } }) export class ShopOrder { - @prop({ type: [String], required: true }) - public items: Array; - - @prop({ type: [Number], required: true }) - public quantity: Array; - @prop({ required: true }) - public userId: string; + public userId!: string; - constructor(items: Array, quantity: Array, userId: string) { - this.items = items; - this.quantity = quantity; + @prop({ type: Map, required: true }) + public items!: Map; + + constructor(items: [string, number][], userId: string) { + this.items = new Map(items); this.userId = userId; } } export const ShopItemIdSchema = z.string().openapi("ShopItemId", { example: "item1234" }); -// export const ShopOrderArraySchema = z -// .tuple([ -// z.array(z.string()), -// z.array(z.number()), -// ]) -// .openapi("ShopOrderArray", { example: [["item1234", "item5678"], [1, 2]] }); export const ShopItemSchema = z .object({ @@ -104,14 +95,8 @@ export const ShopItemUpdateRequestSchema = ShopItemSchema.omit({ itemId: true }) }, }); -export const ShopItemBuyRequestSchema = z.object({ - itemId: ShopItemIdSchema, - instance: z.string().openapi({ example: "1x3" }), -}); - export const ShopOrderInfoSchema = z.object({ - items: z.array(z.string()), - quantity: z.array(z.number()), + items: z.array(z.tuple([z.string(), z.number()])), userId: z.string(), }); @@ -129,12 +114,6 @@ export const OrderQRCodesSchema = z }) .openapi("OrderQRCodes"); -export const SuccessSchema = z - .object({ - message: z.string(), - }) - .openapi("Success"); - export const [ShopItemAlreadyExistsError, ShopItemAlreadyExistsErrorSchema] = CreateErrorAndSchema({ error: "AlreadyExists", message: "An item with that id already exists, did you mean to update it instead?", From 4bb6e1b2bc26f1f58efdf1f9771f2175f5295ae0 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 12:39:59 -0600 Subject: [PATCH 11/15] no more hardcoding numbers! --- src/services/shop/shop-router.test.ts | 268 +++++++++++++++----------- src/services/shop/shop-router.ts | 167 +++++++--------- src/services/shop/shop-schemas.ts | 4 +- 3 files changed, 226 insertions(+), 213 deletions(-) diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts index 71b2029d..35322638 100644 --- a/src/services/shop/shop-router.test.ts +++ b/src/services/shop/shop-router.test.ts @@ -4,6 +4,7 @@ import { StatusCode } from "status-code-enum"; import Models from "../../common/models"; import { ShopItem, ShopOrder } from "./shop-schemas"; import { AttendeeProfile } from "../profile/profile-schemas"; +import { generateQRCode } from "../user/user-lib"; // Define test item const TESTER_SHOP_ITEM = { @@ -15,7 +16,11 @@ const TESTER_SHOP_ITEM = { quantity: 10, } satisfies ShopItem; -const TESTER_SHOP_ORDER = new ShopOrder([["test-item-1", 2]], TESTER.id) satisfies ShopOrder; +// Define an initial order quantity for test consistency. +const INITIAL_ORDER_QUANTITY = 2; + +// Define test order using the initial order quantity. +const TESTER_SHOP_ORDER = new ShopOrder([[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], TESTER.id) satisfies ShopOrder; // Define test profile const TESTER_PROFILE = { @@ -29,12 +34,7 @@ const TESTER_PROFILE = { // Initialize test data before each test beforeEach(async () => { - // Clean up any existing data - await Models.ShopItem.deleteMany({}); - await Models.ShopOrder.deleteMany({}); - await Models.AttendeeProfile.deleteMany({}); - - // Create fresh test data + // Create fresh test data. await Models.ShopItem.create(TESTER_SHOP_ITEM); await Models.ShopOrder.create(TESTER_SHOP_ORDER); await Models.AttendeeProfile.create(TESTER_PROFILE); @@ -45,73 +45,82 @@ beforeEach(async () => { // describe("POST /shop/cart/redeem", () => { it("allows staff to successfully redeem an order", async () => { - const response = await postAsStaff("/shop/cart/redeem") - .send({ userId: TESTER_PROFILE.userId }) - .expect(StatusCode.SuccessOK); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + const response = await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.SuccessOK); // Expect returned order to use the new tuple format. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [["test-item-1", 2]], + items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], }); - // Verify inventory was updated: quantity reduced by 2 (from 10 to 8) - const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); - expect(updatedItem?.quantity).toBe(8); + // Verify inventory was updated: quantity reduced by the order amount. + const updatedItem = await Models.ShopItem.findOne({ + itemId: TESTER_SHOP_ITEM.itemId, + }); + expect(updatedItem?.quantity).toBe(TESTER_SHOP_ITEM.quantity - INITIAL_ORDER_QUANTITY); - // Verify points were deducted (2 * 100 = 200, so 1000-200 = 800) - const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedProfile?.points).toBe(800); + // Verify points were deducted (order quantity * item price). + const updatedProfile = await Models.AttendeeProfile.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedProfile?.points).toBe(TESTER_PROFILE.points - INITIAL_ORDER_QUANTITY * TESTER_SHOP_ITEM.price); - // Verify order was deleted - const deletedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + // Verify order was deleted. + const deletedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); expect(deletedOrder).toBeNull(); }); - it("returns NotFound for non-existent order", async () => { - await postAsStaff("/shop/cart/redeem").send({ userId: "non-existent-user" }).expect(StatusCode.ServerErrorInternal); - }); - - it("returns NotFound for non-existent user profile", async () => { - // Create order but delete profile - await Models.AttendeeProfile.deleteOne({ userId: TESTER_PROFILE.userId }); - - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ServerErrorInternal); - }); - it("returns NotFound for non-existent shop item", async () => { // Update order so that it now references a non-existent item. await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorNotFound); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.ClientErrorNotFound); }); it("returns BadRequest for insufficient item quantity", async () => { - // Request more than available (11 instead of available 10) - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 11]] }); + // Request more than available by using TESTER_SHOP_ITEM.quantity + 1. + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { + items: [[TESTER_SHOP_ITEM.itemId, TESTER_SHOP_ITEM.quantity + 1]], + }, + ); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.ClientErrorBadRequest); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.ClientErrorBadRequest); }); it("returns PaymentRequired for insufficient points", async () => { - // Set the user’s points to 0 + // Set the user’s points to 0. await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 0 }); - await postAsStaff("/shop/cart/redeem") - .send({ userId: TESTER_PROFILE.userId }) - .expect(StatusCode.ClientErrorPaymentRequired); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.ClientErrorPaymentRequired); }); it("handles undefined quantity correctly", async () => { - // Unset the quantity for "test-item-1" in the items map. - // (Assuming the ShopOrder is stored as an object in MongoDB, - // unsetting the field will cause its quantity to be undefined.) - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { $unset: { "items.test-item-1": "" } }); + // Unset the quantity for TESTER_SHOP_ITEM in the items map. + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { $unset: { [`items.${TESTER_SHOP_ITEM.itemId}`]: "" } }, + ); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.SuccessOK); // Since undefined should be treated as 0, the shop item’s quantity should remain unchanged. - const updatedItem = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); + const updatedItem = await Models.ShopItem.findOne({ + itemId: TESTER_SHOP_ITEM.itemId, + }); expect(updatedItem?.quantity).toBe(TESTER_SHOP_ITEM.quantity); }); @@ -124,29 +133,41 @@ describe("POST /shop/cart/redeem", () => { }; await Models.ShopItem.create(secondItem); + // Define quantities for each item. + const qty1 = 1; + const qty2 = 2; + // Update order to include two items: - // "test-item-1": 1 unit and "test-item-2": 2 units. + // TESTER_SHOP_ITEM with qty1 and secondItem with qty2. await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { items: [ - ["test-item-1", 1], - [secondItem.itemId, 2], + [TESTER_SHOP_ITEM.itemId, qty1], + [secondItem.itemId, qty2], ], }, ); - await postAsStaff("/shop/cart/redeem").send({ userId: TESTER_PROFILE.userId }).expect(StatusCode.SuccessOK); + const uri = generateQRCode(TESTER_PROFILE.userId); + const qrValue = uri.split("=")[1]; + await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.SuccessOK); // Verify inventory updates. - const updatedItem1 = await Models.ShopItem.findOne({ itemId: TESTER_SHOP_ITEM.itemId }); - const updatedItem2 = await Models.ShopItem.findOne({ itemId: secondItem.itemId }); - expect(updatedItem1?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 1); - expect(updatedItem2?.quantity).toBe(TESTER_SHOP_ITEM.quantity - 2); - - // Verify total points deduction: (100 * 1) + (50 * 2) - const updatedProfile = await Models.AttendeeProfile.findOne({ userId: TESTER_PROFILE.userId }); - const expectedPoints = TESTER_PROFILE.points - (TESTER_SHOP_ITEM.price * 1 + secondItem.price * 2); + const updatedItem1 = await Models.ShopItem.findOne({ + itemId: TESTER_SHOP_ITEM.itemId, + }); + const updatedItem2 = await Models.ShopItem.findOne({ + itemId: secondItem.itemId, + }); + expect(updatedItem1?.quantity).toBe(TESTER_SHOP_ITEM.quantity - qty1); + expect(updatedItem2?.quantity).toBe(secondItem.quantity - qty2); + + // Verify total points deduction: (price * qty1) + (secondItem.price * qty2). + const updatedProfile = await Models.AttendeeProfile.findOne({ + userId: TESTER_PROFILE.userId, + }); + const expectedPoints = TESTER_PROFILE.points - (TESTER_SHOP_ITEM.price * qty1 + secondItem.price * qty2); expect(updatedProfile?.points).toBe(expectedPoints); }); }); @@ -156,33 +177,38 @@ describe("POST /shop/cart/redeem", () => { // describe("POST /shop/cart/:itemId", () => { it("allows user to add new item to cart", async () => { - const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + const response = await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); - // Since the initial order had 2 units, adding one more should yield 3. + // Since the initial order had INITIAL_ORDER_QUANTITY units, + // adding one more should yield INITIAL_ORDER_QUANTITY + 1. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [["test-item-1", 3]], + items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY + 1]], }); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.get("test-item-1")).toBe(3); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.get(TESTER_SHOP_ITEM.itemId)).toBe(INITIAL_ORDER_QUANTITY + 1); }); it("increases quantity when adding existing item to cart", async () => { // First addition. - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // Second addition. - const response = await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + const response = await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); - // Starting with 2, two additions should result in 4. + // Starting with INITIAL_ORDER_QUANTITY, two additions yield INITIAL_ORDER_QUANTITY + 2. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [["test-item-1", 4]], + items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY + 2]], }); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.get("test-item-1")).toBe(4); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.get(TESTER_SHOP_ITEM.itemId)).toBe(INITIAL_ORDER_QUANTITY + 2); }); it("returns NotFound for non-existent item", async () => { @@ -203,7 +229,8 @@ describe("POST /shop/cart/:itemId", () => { await Models.ShopItem.create({ ...TESTER_SHOP_ITEM, itemId: "expensive-item", - price: 2000, + // Use a price higher than the user's points. + price: TESTER_PROFILE.points + 100, quantity: 1, }); @@ -214,12 +241,14 @@ describe("POST /shop/cart/:itemId", () => { // Delete any existing cart. await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); - const newOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); + const newOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); expect(newOrder).toBeTruthy(); // For a new cart, the item should be added with quantity 1. - expect(newOrder?.items.get("test-item-1")).toBe(1); + expect(newOrder?.items.get(TESTER_SHOP_ITEM.itemId)).toBe(1); }); }); @@ -228,47 +257,53 @@ describe("POST /shop/cart/:itemId", () => { // describe("DELETE /shop/cart/:itemId", () => { it("allows user to remove an item from the cart", async () => { - // Set the cart so that test-item-1 has quantity 1. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 1]] }); + // Set the cart so that TESTER_SHOP_ITEM has quantity 1. + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 1]] }); // Removing the item when its quantity is 1 should remove it completely. - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.has("test-item-1")).toBe(false); + await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.has(TESTER_SHOP_ITEM.itemId)).toBe(false); }); it("decreases the quantity of an item in the cart", async () => { // Start with quantity 0 for the item. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 0]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 0]] }); // Add the item twice. - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); // becomes 1 - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); // becomes 2 + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // becomes 1 + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // becomes 2 // Remove the item once. - const response = await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + const response = await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [["test-item-1", 1]], + items: [[TESTER_SHOP_ITEM.itemId, 1]], }); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.get("test-item-1")).toBe(1); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.get(TESTER_SHOP_ITEM.itemId)).toBe(1); }); it("removes the item completely if the quantity reaches 0", async () => { // Start with quantity 0. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["test-item-1", 0]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 0]] }); // Add the item twice. - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // Remove the item twice. - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); + await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.has("test-item-1")).toBe(false); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.has(TESTER_SHOP_ITEM.itemId)).toBe(false); }); it("returns NotFound for non-existent item in cart", async () => { @@ -277,7 +312,7 @@ describe("DELETE /shop/cart/:itemId", () => { it("returns NotFound when no cart exists for the user", async () => { await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.ClientErrorNotFound); + await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.ClientErrorNotFound); }); it("removes the item completely when quantity reaches 0 in a fresh cart", async () => { @@ -285,13 +320,15 @@ describe("DELETE /shop/cart/:itemId", () => { await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); // Add an item. - await postAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // Now delete the item. - await delAsAttendee("/shop/cart/test-item-1").expect(StatusCode.SuccessOK); + await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); - const updatedOrder = await Models.ShopOrder.findOne({ userId: TESTER_PROFILE.userId }); - expect(updatedOrder?.items.has("test-item-1")).toBe(false); + const updatedOrder = await Models.ShopOrder.findOne({ + userId: TESTER_PROFILE.userId, + }); + expect(updatedOrder?.items.has(TESTER_SHOP_ITEM.itemId)).toBe(false); }); }); @@ -304,7 +341,7 @@ describe("GET /shop/cart", () => { expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [["test-item-1", 2]], + items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], }); }); @@ -322,11 +359,13 @@ describe("GET /shop/cart", () => { it("returns cart with matching items and quantities", async () => { await Models.ShopOrder.findOneAndDelete({ userId: TESTER.id }); + const qty1 = 1; + const qty2 = 3; const shopOrder = { userId: TESTER.id, items: [ - [TESTER_SHOP_ITEM.itemId, 1], - ["test-item-2", 3], + [TESTER_SHOP_ITEM.itemId, qty1], + ["test-item-2", qty2], ], }; await Models.ShopOrder.create(shopOrder); @@ -337,8 +376,8 @@ describe("GET /shop/cart", () => { expect(cart).toMatchObject({ userId: TESTER.id, items: [ - [TESTER_SHOP_ITEM.itemId, 1], - ["test-item-2", 3], + [TESTER_SHOP_ITEM.itemId, qty1], + ["test-item-2", qty2], ], }); }); @@ -352,15 +391,10 @@ describe("GET /shop/cart/qr", () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: `hackillinois://shop?userId=${TESTER_PROFILE.userId}`, + qrInfo: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), }); }); - it("returns InternalServerError for non-existent cart", async () => { - await Models.ShopOrder.deleteOne({ userId: TESTER_PROFILE.userId }); - await getAsAttendee("/shop/cart/qr").expect(StatusCode.ServerErrorInternal); - }); - it("returns NotFound when cart item no longer exists in shop", async () => { // Create a cart with an item that does not exist. await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); @@ -380,8 +414,11 @@ describe("GET /shop/cart/qr", () => { }; await Models.ShopItem.create(lowQuantityItem); - // Create a cart requesting more than available. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["low-quantity-item", 2]] }); + // Create a cart requesting more than available by requesting lowQuantityItem.quantity + 1. + await Models.ShopOrder.updateOne( + { userId: TESTER_PROFILE.userId }, + { items: [[lowQuantityItem.itemId, lowQuantityItem.quantity + 1]] }, + ); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorBadRequest); expect(JSON.parse(response.text)).toMatchObject({ @@ -411,14 +448,15 @@ describe("GET /shop/cart/qr", () => { { userId: TESTER_PROFILE.userId }, { items: [ - ["expensive-item-1", 1], - ["expensive-item-2", 1], + [expensiveItem1.itemId, 1], + [expensiveItem2.itemId, 1], ], }, ); // Set user points to less than the total price. - await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 1000 }); + const totalPrice = expensiveItem1.price + expensiveItem2.price; + await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: totalPrice - 1 }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorPaymentRequired); expect(JSON.parse(response.text)).toMatchObject({ @@ -431,20 +469,20 @@ describe("GET /shop/cart/qr", () => { const item = { ...TESTER_SHOP_ITEM, itemId: "exact-price-item", - price: 100, + price: TESTER_SHOP_ITEM.price, quantity: 1, }; await Models.ShopItem.create(item); // Create a cart with this item. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["exact-price-item", 1]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[item.itemId, 1]] }); // Set the user's points to the exact amount needed. - await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: 100 }); + await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: item.price }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: `hackillinois://shop?userId=${TESTER_PROFILE.userId}`, + qrInfo: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), }); }); }); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 12cd9896..88fd0442 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -11,8 +11,6 @@ import { ShopOrder, OrderQRCodesSchema, ShopInsufficientQuantityError, - ShopInternalErrorSchema, - ShopInternalError, ShopOrderInfoSchema, } from "./shop-schemas"; import { Router } from "express"; @@ -23,6 +21,7 @@ import specification, { Tag } from "../../middleware/specification"; import { z } from "zod"; import { updatePoints } from "../profile/profile-lib"; import { getAuthenticatedUser } from "../../common/auth"; +import { decryptQRCode, generateQRCode } from "../user/user-lib"; const shopRouter = Router(); shopRouter.get( @@ -61,7 +60,7 @@ shopRouter.post( schema: ShopOrderInfoSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Shop Item DNE", + description: "Shop Item doesn't exist", schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { @@ -72,87 +71,79 @@ shopRouter.post( description: "Not enough points to purchase", schema: ShopInsufficientFundsErrorSchema, }, - [StatusCode.ServerErrorInternal]: { - description: "Errors that should never happen", - schema: ShopInternalErrorSchema, - }, }, }), async (req, res) => { - try { - const { userId } = req.body; + const { QRCode } = req.body; + const userId = decryptQRCode(QRCode); - // Retrieve the user's order - const order = await Models.ShopOrder.findOne({ userId }); - if (!order) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); - } + // Retrieve the user's order + const order = await Models.ShopOrder.findOne({ userId }); + if (!order) { + throw new Error("nonexistent order"); + } - // Retrieve the user's profile - const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); - if (!profile) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); - } + // Retrieve the user's profile + const profile = await Models.AttendeeProfile.findOne({ userId: order.userId }); + if (!profile) { + throw new Error("nonexistent profile"); + } - // Gather all item IDs from the order - const itemIds = Array.from(order.items.keys()); + // Gather all item IDs from the order + const itemIds = Array.from(order.items.keys()); - // Fetch all shop items with one query - const items = await Models.ShopItem.find({ itemId: { $in: itemIds } }); + // Fetch all shop items with one query + const items = await Models.ShopItem.find({ itemId: { $in: itemIds } }); - // Create a map of itemId to item document for easy lookup - const itemsMap = new Map(items.map((item) => [item.itemId, item])); + // Create a map of itemId to item document for easy lookup + const itemsMap = new Map(items.map((item) => [item.itemId, item])); - let totalPointsRequired = 0; + let totalPointsRequired = 0; - // Loop through each order item to check availability and calculate total points required - for (const [itemId, quantity] of order.items.entries()) { - const item = itemsMap.get(itemId); - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } + // Loop through each order item to check availability and calculate total points required + for (const [itemId, quantity] of order.items.entries()) { + const item = itemsMap.get(itemId); + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } - totalPointsRequired += quantity * item.price; + totalPointsRequired += quantity * item.price; - // Check if the requested quantity is available - if (quantity > item.quantity) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); - } + // Check if the requested quantity is available + if (quantity > item.quantity) { + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } + } - // Check if the user has enough points for the order - if (profile.points < totalPointsRequired) { - return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); - } + // Check if the user has enough points for the order + if (profile.points < totalPointsRequired) { + return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); + } - // Update the inventory and deduct points - for (const [itemId, quantity] of order.items.entries()) { - const item = itemsMap.get(itemId); - if (!item) { - return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); - } + // Update the inventory and deduct points + for (const [itemId, quantity] of order.items.entries()) { + const item = itemsMap.get(itemId); + if (!item) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } - // Deduct the item's quantity from stock - await Models.ShopItem.updateOne({ itemId }, { $inc: { quantity: -quantity } }); + // Deduct the item's quantity from stock + await Models.ShopItem.updateOne({ itemId }, { $inc: { quantity: -quantity } }); - // Deduct points from the user's profile - await updatePoints(order.userId, -(quantity * item.price)); - } + // Deduct points from the user's profile + await updatePoints(order.userId, -(quantity * item.price)); + } - // Clear the user's order from the cart - await Models.ShopOrder.deleteOne({ userId }); + // Clear the user's order from the cart + await Models.ShopOrder.deleteOne({ userId }); - // Convert order.items (a Map) to an array of tuples since Zod doesn't support maps - const zodOrder = { - userId: order.userId, - items: Array.from(order.items), - }; + // Convert order.items (a Map) to an array of tuples since Zod doesn't support maps + const zodOrder = { + userId: order.userId, + items: Array.from(order.items), + }; - return res.status(StatusCode.SuccessOK).json(zodOrder); - } catch (error) { - console.error("Error processing order:", error); - return res.status(StatusCode.ServerErrorInternal).json(ShopInternalError); - } + return res.status(StatusCode.SuccessOK).json(zodOrder); }, ); @@ -173,7 +164,7 @@ shopRouter.post( schema: ShopOrderInfoSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Shop Item DNE", + description: "Shop Item doesn't exist", schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { @@ -184,10 +175,6 @@ shopRouter.post( description: "Not enough points to purchase", schema: ShopInsufficientFundsErrorSchema, }, - [StatusCode.ServerErrorInternal]: { - description: "Errors that should never happen", - schema: ShopInternalErrorSchema, - }, }, }), async (req, res) => { @@ -203,7 +190,7 @@ shopRouter.post( // This should never get hit, just checking here so typescript doesn't get mad if (!userOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("nonexistent order"); } // check if enough quantity in shop @@ -220,7 +207,7 @@ shopRouter.post( const profile = await Models.AttendeeProfile.findOne({ userId: userId }); // This should never get hit. if (!profile) { - return res.status(StatusCode.ServerErrorInternal).json(ShopInternalError); + throw new Error("nonexistent profile"); } if (profile.points < item.price) { @@ -237,7 +224,7 @@ shopRouter.post( }, ); if (!updatedOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("internal db query error"); } } else { const updatedOrder = await Models.ShopOrder.updateOne( @@ -247,7 +234,7 @@ shopRouter.post( }, ); if (!updatedOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("internal db query error"); } } @@ -260,7 +247,7 @@ shopRouter.post( }; return res.status(StatusCode.SuccessOK).json(zodOrder); } - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("internal db query error"); }, ); @@ -281,13 +268,9 @@ shopRouter.delete( schema: ShopOrderInfoSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Shop Item DNE", + description: "Shop Item doesn't exist", schema: ShopItemNotFoundErrorSchema, }, - [StatusCode.ServerErrorInternal]: { - description: "Errors that should never happen", - schema: ShopInternalErrorSchema, - }, }, }), async (req, res) => { @@ -316,7 +299,7 @@ shopRouter.delete( // If update fails if (!updatedShopOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); // Internal error + throw new Error("internal db query error"); } // If the quantity of the item becomes 0, remove the item from the cart @@ -338,7 +321,7 @@ shopRouter.delete( }; return res.status(StatusCode.SuccessOK).json(zodOrder); } - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("internal db query error"); }, ); @@ -355,10 +338,6 @@ shopRouter.get( description: "List of items and quantity", schema: ShopOrderInfoSchema, }, - [StatusCode.ServerErrorInternal]: { - description: "Errors that should never happen", - schema: ShopInternalErrorSchema, - }, }, }), async (req, res) => { @@ -371,7 +350,7 @@ shopRouter.get( } if (!userOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("nonexistent order"); } // Convert order to array of tuples cuz zod doesn't fw maps @@ -397,21 +376,17 @@ shopRouter.get( schema: OrderQRCodesSchema, }, [StatusCode.ClientErrorNotFound]: { - description: "Shop Item DNE", + description: "Shop Item doesn't exist", schema: ShopItemNotFoundErrorSchema, }, [StatusCode.ClientErrorBadRequest]: { description: "Not enough quantity in shop", - schema: ShopInsufficientFundsErrorSchema, + schema: ShopInsufficientQuantityErrorSchema, }, [StatusCode.ClientErrorPaymentRequired]: { description: "User doesn't have enough points to purchase", schema: ShopInsufficientFundsErrorSchema, }, - [StatusCode.ServerErrorInternal]: { - description: "Errors that should never happen", - schema: ShopInternalErrorSchema, - }, }, }), async (req, res) => { @@ -420,7 +395,7 @@ shopRouter.get( // Fetch user order const userOrder = await Models.ShopOrder.findOne({ userId }); if (!userOrder) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("nonexistent order"); } // Get all item IDs from the order's map @@ -437,14 +412,14 @@ shopRouter.get( return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); } if (currentQuantity > item.quantity) { - return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientFundsError); + return res.status(StatusCode.ClientErrorBadRequest).send(ShopInsufficientQuantityError); } } // Fetch user profile once const profile = await Models.AttendeeProfile.findOne({ userId }); if (!profile) { - return res.status(StatusCode.ServerErrorInternal).send(ShopInternalError); + throw new Error("nonexistent profile"); } // Compute total cost @@ -460,7 +435,7 @@ shopRouter.get( } // Generate QR code - const qrCodeUrl = `hackillinois://shop?userId=${userId}`; + const qrCodeUrl = generateQRCode(userId); return res.status(StatusCode.SuccessOK).send({ qrInfo: qrCodeUrl }); }, ); diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 7d610cd1..bf01ba31 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -101,11 +101,11 @@ export const ShopOrderInfoSchema = z.object({ }); export const ShopItemFulfillOrderSchema = z.object({ - userId: z.string(), + QRCode: z.string(), }); export const OrderQRCodeSchema = z.string().openapi("OrderQRCode", { - example: "hackillinois://shop?userId=github1203919029", + example: "hackillinois://user?qr=github1203919029", }); export const OrderQRCodesSchema = z From c0c89daa3aa8810588d687cd37054adbc21bb2f7 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:04:28 -0600 Subject: [PATCH 12/15] removed shopInternalError --- src/services/shop/shop-schemas.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index bf01ba31..c05fe18c 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -133,8 +133,3 @@ export const [ShopInsufficientQuantityError, ShopInsufficientQuantityErrorSchema error: "InsufficientQuantity", message: "Not enough of that item in the shop/your cart", }); - -export const [ShopInternalError, ShopInternalErrorSchema] = CreateErrorAndSchema({ - error: "InternalError", - message: "This should never happen. i.e. user without attendeeProfile, user without shopOrder, etc.", -}); From 2d4c2a0b6d3840ed1fe52de07db6ac9e3ecb0393 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:12:56 -0600 Subject: [PATCH 13/15] readded original shop endpoints --- src/services/shop/shop-router.ts | 121 ++++++++++++++++++++++++++++++ src/services/shop/shop-schemas.ts | 4 +- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 88fd0442..4eeee298 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -12,6 +12,11 @@ import { OrderQRCodesSchema, ShopInsufficientQuantityError, ShopOrderInfoSchema, + ShopItemSchema, + ShopItemUpdateRequestSchema, + ShopItemAlreadyExistsError, + ShopItemCreateRequestSchema, + ShopItemAlreadyExistsErrorSchema, } from "./shop-schemas"; import { Router } from "express"; import { StatusCode } from "status-code-enum"; @@ -22,6 +27,8 @@ import { z } from "zod"; import { updatePoints } from "../profile/profile-lib"; import { getAuthenticatedUser } from "../../common/auth"; import { decryptQRCode, generateQRCode } from "../user/user-lib"; +import { SuccessResponseSchema } from "../../common/schemas"; +import { randomUUID } from "crypto"; const shopRouter = Router(); shopRouter.get( @@ -45,6 +52,120 @@ shopRouter.get( }, ); +shopRouter.post( + "/item", + specification({ + method: "post", + path: "/shop/item/", + tag: Tag.SHOP, + role: Role.ADMIN, + summary: "Creates a shop item", + body: ShopItemCreateRequestSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The new item", + schema: ShopItemSchema, + }, + [StatusCode.ClientErrorConflict]: { + description: "The item already exists", + schema: ShopItemAlreadyExistsErrorSchema, + }, + }, + }), + async (req, res) => { + const details = req.body; + const itemId = randomUUID(); + + const shopItem: ShopItem = { + ...details, + itemId: itemId, + }; + + // Ensure that item doesn't already exist before creating + const itemExists = (await Models.ShopItem.findOne({ name: details.name })) ?? false; + if (itemExists) { + return res.status(StatusCode.ClientErrorConflict).send(ShopItemAlreadyExistsError); + } + + const newItem = await Models.ShopItem.create(shopItem); + + return res.status(StatusCode.SuccessOK).send(newItem); + }, +); + +shopRouter.put( + "/item/:id/", + specification({ + method: "put", + path: "/shop/item/{id}/", + tag: Tag.SHOP, + role: Role.ADMIN, + summary: "Updates a shop item", + parameters: z.object({ + id: ShopItemIdSchema, + }), + body: ShopItemUpdateRequestSchema, + responses: { + [StatusCode.SuccessOK]: { + description: "The new item", + schema: ShopItemSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Item doesn't exist", + schema: ShopItemNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { id: itemId } = req.params; + const updateRequest = req.body; + + const updatedItem = await Models.ShopItem.findOneAndUpdate({ itemId }, updateRequest, { + new: true, + }); + + if (!updatedItem) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + return res.status(StatusCode.SuccessOK).send(updatedItem); + }, +); + +shopRouter.delete( + "/item/:id/", + specification({ + method: "delete", + path: "/shop/item/{id}/", + tag: Tag.SHOP, + role: Role.ADMIN, + summary: "Deletes a shop item", + parameters: z.object({ + id: ShopItemIdSchema, + }), + responses: { + [StatusCode.SuccessOK]: { + description: "Successfully deleted", + schema: SuccessResponseSchema, + }, + [StatusCode.ClientErrorNotFound]: { + description: "Item doesn't exist", + schema: ShopItemNotFoundErrorSchema, + }, + }, + }), + async (req, res) => { + const { id: itemId } = req.params; + const deleted = await Models.ShopItem.deleteOne({ itemId }); + + if (deleted.deletedCount == 0) { + return res.status(StatusCode.ClientErrorNotFound).send(ShopItemNotFoundError); + } + + return res.status(StatusCode.SuccessOK).send({ success: true }); + }, +); + shopRouter.post( "/cart/redeem", specification({ diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index c05fe18c..18e1e205 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -45,7 +45,7 @@ export class ShopOrder { } } -export const ShopItemIdSchema = z.string().openapi("ShopItemId", { example: "item1234" }); +export const ShopItemIdSchema = z.string().openapi("ShopItemId", { example: "3e7eea9a-7264-4ddf-877d-9e004a888eda" }); export const ShopItemSchema = z .object({ @@ -58,7 +58,7 @@ export const ShopItemSchema = z }) .openapi("ShopItem", { example: { - itemId: "1234", + itemId: "3e7eea9a-7264-4ddf-877d-9e004a888eda", name: "HackIllinois Branded Hoodie", price: 15, isRaffle: true, From 143d86f6e30615e864615adcad59026fc4a79366 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:31:54 -0600 Subject: [PATCH 14/15] using record now --- src/services/shop/shop-router.test.ts | 63 ++++++++++++++------------- src/services/shop/shop-router.ts | 40 +++++++++-------- src/services/shop/shop-schemas.ts | 2 +- 3 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts index 35322638..2bead33f 100644 --- a/src/services/shop/shop-router.test.ts +++ b/src/services/shop/shop-router.test.ts @@ -20,6 +20,7 @@ const TESTER_SHOP_ITEM = { const INITIAL_ORDER_QUANTITY = 2; // Define test order using the initial order quantity. +// (The constructor still takes an array of tuples, but the stored value will be a Map.) const TESTER_SHOP_ORDER = new ShopOrder([[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], TESTER.id) satisfies ShopOrder; // Define test profile @@ -49,10 +50,10 @@ describe("POST /shop/cart/redeem", () => { const qrValue = uri.split("=")[1]; const response = await postAsStaff("/shop/cart/redeem").send({ QRCode: qrValue }).expect(StatusCode.SuccessOK); - // Expect returned order to use the new tuple format. + // Expect returned order to use the new object format. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], + items: { [TESTER_SHOP_ITEM.itemId]: INITIAL_ORDER_QUANTITY }, }); // Verify inventory was updated: quantity reduced by the order amount. @@ -76,7 +77,7 @@ describe("POST /shop/cart/redeem", () => { it("returns NotFound for non-existent shop item", async () => { // Update order so that it now references a non-existent item. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { "non-existent-item": 1 } }); const uri = generateQRCode(TESTER_PROFILE.userId); const qrValue = uri.split("=")[1]; @@ -88,7 +89,7 @@ describe("POST /shop/cart/redeem", () => { await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { - items: [[TESTER_SHOP_ITEM.itemId, TESTER_SHOP_ITEM.quantity + 1]], + items: { [TESTER_SHOP_ITEM.itemId]: TESTER_SHOP_ITEM.quantity + 1 }, }, ); @@ -142,10 +143,10 @@ describe("POST /shop/cart/redeem", () => { await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { - items: [ - [TESTER_SHOP_ITEM.itemId, qty1], - [secondItem.itemId, qty2], - ], + items: { + [TESTER_SHOP_ITEM.itemId]: qty1, + [secondItem.itemId]: qty2, + }, }, ); @@ -183,7 +184,7 @@ describe("POST /shop/cart/:itemId", () => { // adding one more should yield INITIAL_ORDER_QUANTITY + 1. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY + 1]], + items: { [TESTER_SHOP_ITEM.itemId]: INITIAL_ORDER_QUANTITY + 1 }, }); const updatedOrder = await Models.ShopOrder.findOne({ @@ -202,7 +203,7 @@ describe("POST /shop/cart/:itemId", () => { // Starting with INITIAL_ORDER_QUANTITY, two additions yield INITIAL_ORDER_QUANTITY + 2. expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY + 2]], + items: { [TESTER_SHOP_ITEM.itemId]: INITIAL_ORDER_QUANTITY + 2 }, }); const updatedOrder = await Models.ShopOrder.findOne({ @@ -258,7 +259,7 @@ describe("POST /shop/cart/:itemId", () => { describe("DELETE /shop/cart/:itemId", () => { it("allows user to remove an item from the cart", async () => { // Set the cart so that TESTER_SHOP_ITEM has quantity 1. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 1]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { [TESTER_SHOP_ITEM.itemId]: 1 } }); // Removing the item when its quantity is 1 should remove it completely. await delAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); @@ -270,7 +271,7 @@ describe("DELETE /shop/cart/:itemId", () => { it("decreases the quantity of an item in the cart", async () => { // Start with quantity 0 for the item. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 0]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { [TESTER_SHOP_ITEM.itemId]: 0 } }); // Add the item twice. await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // becomes 1 await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); // becomes 2 @@ -280,7 +281,7 @@ describe("DELETE /shop/cart/:itemId", () => { expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [[TESTER_SHOP_ITEM.itemId, 1]], + items: { [TESTER_SHOP_ITEM.itemId]: 1 }, }); const updatedOrder = await Models.ShopOrder.findOne({ @@ -291,7 +292,7 @@ describe("DELETE /shop/cart/:itemId", () => { it("removes the item completely if the quantity reaches 0", async () => { // Start with quantity 0. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[TESTER_SHOP_ITEM.itemId, 0]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { [TESTER_SHOP_ITEM.itemId]: 0 } }); // Add the item twice. await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); await postAsAttendee(`/shop/cart/${TESTER_SHOP_ITEM.itemId}`).expect(StatusCode.SuccessOK); @@ -341,7 +342,7 @@ describe("GET /shop/cart", () => { expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER_PROFILE.userId, - items: [[TESTER_SHOP_ITEM.itemId, INITIAL_ORDER_QUANTITY]], + items: { [TESTER_SHOP_ITEM.itemId]: INITIAL_ORDER_QUANTITY }, }); }); @@ -353,7 +354,7 @@ describe("GET /shop/cart", () => { expect(JSON.parse(response.text)).toMatchObject({ userId: TESTER.id, - items: [], + items: {}, }); }); @@ -363,10 +364,10 @@ describe("GET /shop/cart", () => { const qty2 = 3; const shopOrder = { userId: TESTER.id, - items: [ - [TESTER_SHOP_ITEM.itemId, qty1], - ["test-item-2", qty2], - ], + items: { + [TESTER_SHOP_ITEM.itemId]: qty1, + "test-item-2": qty2, + }, }; await Models.ShopOrder.create(shopOrder); @@ -375,10 +376,10 @@ describe("GET /shop/cart", () => { expect(cart).toMatchObject({ userId: TESTER.id, - items: [ - [TESTER_SHOP_ITEM.itemId, qty1], - ["test-item-2", qty2], - ], + items: { + [TESTER_SHOP_ITEM.itemId]: qty1, + "test-item-2": qty2, + }, }); }); }); @@ -397,7 +398,7 @@ describe("GET /shop/cart/qr", () => { it("returns NotFound when cart item no longer exists in shop", async () => { // Create a cart with an item that does not exist. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [["non-existent-item", 1]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { "non-existent-item": 1 } }); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorNotFound); expect(JSON.parse(response.text)).toMatchObject({ @@ -417,7 +418,7 @@ describe("GET /shop/cart/qr", () => { // Create a cart requesting more than available by requesting lowQuantityItem.quantity + 1. await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, - { items: [[lowQuantityItem.itemId, lowQuantityItem.quantity + 1]] }, + { items: { [lowQuantityItem.itemId]: lowQuantityItem.quantity + 1 } }, ); const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.ClientErrorBadRequest); @@ -447,10 +448,10 @@ describe("GET /shop/cart/qr", () => { await Models.ShopOrder.updateOne( { userId: TESTER_PROFILE.userId }, { - items: [ - [expensiveItem1.itemId, 1], - [expensiveItem2.itemId, 1], - ], + items: { + [expensiveItem1.itemId]: 1, + [expensiveItem2.itemId]: 1, + }, }, ); @@ -475,7 +476,7 @@ describe("GET /shop/cart/qr", () => { await Models.ShopItem.create(item); // Create a cart with this item. - await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: [[item.itemId, 1]] }); + await Models.ShopOrder.updateOne({ userId: TESTER_PROFILE.userId }, { items: { [item.itemId]: 1 } }); // Set the user's points to the exact amount needed. await Models.AttendeeProfile.updateOne({ userId: TESTER_PROFILE.userId }, { points: item.price }); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 4eeee298..12cc121b 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -241,6 +241,7 @@ shopRouter.post( return res.status(StatusCode.ClientErrorPaymentRequired).send(ShopInsufficientFundsError); } + let totalPrice = 0; // Update the inventory and deduct points for (const [itemId, quantity] of order.items.entries()) { const item = itemsMap.get(itemId); @@ -252,8 +253,9 @@ shopRouter.post( await Models.ShopItem.updateOne({ itemId }, { $inc: { quantity: -quantity } }); // Deduct points from the user's profile - await updatePoints(order.userId, -(quantity * item.price)); + totalPrice = totalPrice + quantity * item.price; } + await updatePoints(order.userId, -totalPrice); // Clear the user's order from the cart await Models.ShopOrder.deleteOne({ userId }); @@ -261,7 +263,7 @@ shopRouter.post( // Convert order.items (a Map) to an array of tuples since Zod doesn't support maps const zodOrder = { userId: order.userId, - items: Array.from(order.items), + items: Object.fromEntries(order.items.entries()), }; return res.status(StatusCode.SuccessOK).json(zodOrder); @@ -360,15 +362,15 @@ shopRouter.post( } const updatedOrder = await Models.ShopOrder.findOne({ userId }); - if (updatedOrder) { - // Convert order to array of tuples cuz zod doesn't fw maps - const zodOrder = { - userId: updatedOrder.userId, - items: Array.from(updatedOrder.items), - }; - return res.status(StatusCode.SuccessOK).json(zodOrder); + if (!updatedOrder) { + throw new Error("internal db query error"); } - throw new Error("internal db query error"); + // Convert order to array of tuples cuz zod doesn't fw maps + const zodOrder = { + userId: updatedOrder.userId, + items: Object.fromEntries(updatedOrder.items.entries()), + }; + return res.status(StatusCode.SuccessOK).json(zodOrder); }, ); @@ -434,15 +436,15 @@ shopRouter.delete( } const updatedOrder = await Models.ShopOrder.findOne({ userId }); - if (updatedOrder) { - // Convert order to array of tuples cuz zod doesn't fw maps - const zodOrder = { - userId: updatedOrder.userId, - items: Array.from(updatedOrder.items), - }; - return res.status(StatusCode.SuccessOK).json(zodOrder); + if (!updatedOrder) { + throw new Error("internal db query error"); } - throw new Error("internal db query error"); + // Convert order to array of tuples cuz zod doesn't fw maps + const zodOrder = { + userId: updatedOrder.userId, + items: Object.fromEntries(updatedOrder.items.entries()), + }; + return res.status(StatusCode.SuccessOK).json(zodOrder); }, ); @@ -477,7 +479,7 @@ shopRouter.get( // Convert order to array of tuples cuz zod doesn't fw maps const zodOrder = { userId: userOrder.userId, - items: Array.from(userOrder.items), + items: Object.fromEntries(userOrder.items.entries()), }; return res.status(StatusCode.SuccessOK).send(zodOrder); }, diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index 18e1e205..c47d3d76 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -96,7 +96,7 @@ export const ShopItemUpdateRequestSchema = ShopItemSchema.omit({ itemId: true }) }); export const ShopOrderInfoSchema = z.object({ - items: z.array(z.tuple([z.string(), z.number()])), + items: z.record(z.number()), userId: z.string(), }); From aea34fb4b0fcbbfd188211ac3ca8f37d341f0217 Mon Sep 17 00:00:00 2001 From: Alex Yang <32620988+DatProJack@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:44:36 -0600 Subject: [PATCH 15/15] nits --- src/services/shop/shop-router.test.ts | 4 ++-- src/services/shop/shop-router.ts | 10 +++++----- src/services/shop/shop-schemas.ts | 18 ++++++++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/services/shop/shop-router.test.ts b/src/services/shop/shop-router.test.ts index 2bead33f..582668bc 100644 --- a/src/services/shop/shop-router.test.ts +++ b/src/services/shop/shop-router.test.ts @@ -392,7 +392,7 @@ describe("GET /shop/cart/qr", () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), + QRCode: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), }); }); @@ -483,7 +483,7 @@ describe("GET /shop/cart/qr", () => { const response = await getAsAttendee("/shop/cart/qr").expect(StatusCode.SuccessOK); expect(JSON.parse(response.text)).toMatchObject({ - qrInfo: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), + QRCode: expect.stringMatching(/^hackillinois:\/\/user\?qr=.+$/), }); }); }); diff --git a/src/services/shop/shop-router.ts b/src/services/shop/shop-router.ts index 12cc121b..d82952e4 100644 --- a/src/services/shop/shop-router.ts +++ b/src/services/shop/shop-router.ts @@ -7,9 +7,8 @@ import { ShopItemNotFoundError, ShopItemNotFoundErrorSchema, ShopItemsSchema, - ShopItemFulfillOrderSchema, + OrderRequestSchema, ShopOrder, - OrderQRCodesSchema, ShopInsufficientQuantityError, ShopOrderInfoSchema, ShopItemSchema, @@ -17,6 +16,7 @@ import { ShopItemAlreadyExistsError, ShopItemCreateRequestSchema, ShopItemAlreadyExistsErrorSchema, + OrderRedeemSchema, } from "./shop-schemas"; import { Router } from "express"; import { StatusCode } from "status-code-enum"; @@ -174,7 +174,7 @@ shopRouter.post( tag: Tag.SHOP, role: Role.STAFF, summary: "Purchases the order", - body: ShopItemFulfillOrderSchema, + body: OrderRequestSchema, responses: { [StatusCode.SuccessOK]: { description: "The successfully purchased order", @@ -496,7 +496,7 @@ shopRouter.get( responses: { [StatusCode.SuccessOK]: { description: "QR code", - schema: OrderQRCodesSchema, + schema: OrderRedeemSchema, }, [StatusCode.ClientErrorNotFound]: { description: "Shop Item doesn't exist", @@ -559,7 +559,7 @@ shopRouter.get( // Generate QR code const qrCodeUrl = generateQRCode(userId); - return res.status(StatusCode.SuccessOK).send({ qrInfo: qrCodeUrl }); + return res.status(StatusCode.SuccessOK).send({ QRCode: qrCodeUrl }); }, ); diff --git a/src/services/shop/shop-schemas.ts b/src/services/shop/shop-schemas.ts index c47d3d76..9c3a24f6 100644 --- a/src/services/shop/shop-schemas.ts +++ b/src/services/shop/shop-schemas.ts @@ -100,19 +100,21 @@ export const ShopOrderInfoSchema = z.object({ userId: z.string(), }); -export const ShopItemFulfillOrderSchema = z.object({ - QRCode: z.string(), -}); - export const OrderQRCodeSchema = z.string().openapi("OrderQRCode", { - example: "hackillinois://user?qr=github1203919029", + example: "hackillinois://user?qr=3e7eea9a-7264-4ddf-877d-9e004a888eda", }); -export const OrderQRCodesSchema = z +export const OrderRequestSchema = z + .object({ + QRCode: OrderQRCodeSchema, + }) + .openapi("OrderRequest"); + +export const OrderRedeemSchema = z .object({ - qrInfo: z.string(OrderQRCodeSchema), + QRCode: OrderQRCodeSchema, }) - .openapi("OrderQRCodes"); + .openapi("OrderRedeem"); export const [ShopItemAlreadyExistsError, ShopItemAlreadyExistsErrorSchema] = CreateErrorAndSchema({ error: "AlreadyExists",