From 69567e98f87726aa0731717fd38c061f33849a01 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 10 Nov 2025 01:46:32 -0600 Subject: [PATCH] Redesign ticketing check-in process - Require UIN or iCard swipe - Do not call UIN endpoint on client side, instead call it on the server side to enforce no NetID can be used. - Filter tickets for current event --- src/api/functions/tickets.ts | 8 ++ src/api/functions/uin.ts | 53 ++++++++- src/api/routes/tickets.ts | 21 +++- src/api/routes/user.ts | 49 ++------ src/common/types/generic.ts | 13 +++ src/common/types/user.ts | 3 +- src/ui/pages/tickets/ScanTickets.page.tsx | 99 ++++------------ terraform/modules/frontend/main.tf | 14 +++ tests/unit/functions/tickets.test.ts | 3 + tests/unit/tickets.test.ts | 136 ++++++++++++++++++---- 10 files changed, 256 insertions(+), 143 deletions(-) diff --git a/src/api/functions/tickets.ts b/src/api/functions/tickets.ts index cab26de8..928e5efa 100644 --- a/src/api/functions/tickets.ts +++ b/src/api/functions/tickets.ts @@ -9,6 +9,7 @@ export type GetUserPurchasesInputs = { dynamoClient: DynamoDBClient; email: string; logger: ValidLoggers; + productId?: string; }; export type RawTicketEntry = { @@ -36,6 +37,7 @@ export async function getUserTicketingPurchases({ dynamoClient, email, logger, + productId, }: GetUserPurchasesInputs) { const issuedTickets: TicketInfoEntry[] = []; const ticketCommand = new QueryCommand({ @@ -44,7 +46,9 @@ export async function getUserTicketingPurchases({ KeyConditionExpression: "ticketholder_netid = :email", ExpressionAttributeValues: { ":email": { S: email }, + ...(productId && { ":productId": { S: productId } }), }, + ...(productId && { FilterExpression: "event_id = :productId" }), }); let ticketResults; try { @@ -85,6 +89,7 @@ export async function getUserMerchPurchases({ dynamoClient, email, logger, + productId, }: GetUserPurchasesInputs) { const issuedTickets: TicketInfoEntry[] = []; const merchCommand = new QueryCommand({ @@ -93,7 +98,9 @@ export async function getUserMerchPurchases({ KeyConditionExpression: "email = :email", ExpressionAttributeValues: { ":email": { S: email }, + ...(productId && { ":productId": { S: productId } }), }, + ...(productId && { FilterExpression: "item_id = :productId" }), }); let ticketsResult; try { @@ -122,6 +129,7 @@ export async function getUserMerchPurchases({ email: item.email, productId: item.item_id, quantity: item.quantity, + size: item.size, }, refunded: item.refunded, fulfilled: item.fulfilled, diff --git a/src/api/functions/uin.ts b/src/api/functions/uin.ts index 45e79aaf..251fd22d 100644 --- a/src/api/functions/uin.ts +++ b/src/api/functions/uin.ts @@ -1,13 +1,15 @@ import { DynamoDBClient, PutItemCommand, + QueryCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; -import { marshall } from "@aws-sdk/util-dynamodb"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { argon2id, hash } from "argon2"; import { genericConfig } from "common/config.js"; import { BaseError, + DatabaseFetchError, EntraFetchError, InternalServerError, UnauthenticatedError, @@ -184,3 +186,52 @@ export async function saveHashedUserUin({ }), ); } + +export async function getUserIdByUin({ + dynamoClient, + uin, + pepper, +}: { + dynamoClient: DynamoDBClient; + uin: string; + pepper: string; +}): Promise<{ id: string }> { + const uinHash = await getUinHash({ + pepper, + uin, + }); + + const queryCommand = new QueryCommand({ + TableName: genericConfig.UserInfoTable, + IndexName: "UinHashIndex", + KeyConditionExpression: "uinHash = :hash", + ExpressionAttributeValues: { + ":hash": { S: uinHash }, + }, + }); + + const response = await dynamoClient.send(queryCommand); + + if (!response || !response.Items) { + throw new DatabaseFetchError({ + message: "Failed to retrieve user from database.", + }); + } + + if (response.Items.length === 0) { + throw new ValidationError({ + message: + "Failed to find user in database. Please have the user run sync and try again.", + }); + } + + if (response.Items.length > 1) { + throw new ValidationError({ + message: + "Multiple users tied to this UIN. This user probably had a NetID change. Please contact support.", + }); + } + + const data = unmarshall(response.Items[0]) as { id: string }; + return data; +} diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index a8bf4cbb..f50819ac 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -32,6 +32,8 @@ import { getUserMerchPurchases, getUserTicketingPurchases, } from "api/functions/tickets.js"; +import { illinoisUin } from "common/types/generic.js"; +import { getUserIdByUin } from "api/functions/uin.js"; const postMerchSchema = z.object({ type: z.literal("merch"), @@ -512,15 +514,18 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); }, ); - fastify.withTypeProvider().get( - "/purchases/:email", + fastify.withTypeProvider().post( + "/getPurchasesByUser", { schema: withRoles( [AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER], withTags(["Tickets/Merchandise"], { summary: "Get all purchases (merch and tickets) for a given user.", - params: z.object({ - email: z.email(), + body: z.object({ + productId: z.string().min(1).meta({ + description: "The product ID currently being verified", + }), + uin: illinoisUin, }), response: { 200: { @@ -540,18 +545,24 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { - const userEmail = request.params.email; + const { id: userEmail } = await getUserIdByUin({ + dynamoClient: fastify.dynamoClient, + uin: request.body.uin, + pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, + }); try { const [ticketsResult, merchResult] = await Promise.all([ getUserTicketingPurchases({ dynamoClient: UsEast1DynamoClient, email: userEmail, logger: request.log, + productId: request.body.productId, }), getUserMerchPurchases({ dynamoClient: UsEast1DynamoClient, email: userEmail, logger: request.log, + productId: request.body.productId, }), ]); await reply.send({ merch: merchResult, tickets: ticketsResult }); diff --git a/src/api/routes/user.ts b/src/api/routes/user.ts index 5b1541f9..b14a3b29 100644 --- a/src/api/routes/user.ts +++ b/src/api/routes/user.ts @@ -12,7 +12,7 @@ import { searchUserByUinRequest, searchUserByUinResponse, } from "common/types/user.js"; -import { getUinHash } from "api/functions/uin.js"; +import { getUinHash, getUserIdByUin } from "api/functions/uin.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { QueryCommand } from "@aws-sdk/client-dynamodb"; import { genericConfig } from "common/config.js"; @@ -30,11 +30,7 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => { "/findUserByUin", { schema: withRoles( - [ - AppRoles.VIEW_USER_INFO, - AppRoles.TICKETS_MANAGER, - AppRoles.TICKETS_SCANNER, - ], + [AppRoles.VIEW_USER_INFO], withTags(["Generic"], { summary: "Find a user by UIN.", body: searchUserByUinRequest, @@ -53,40 +49,13 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => { onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { - const uinHash = await getUinHash({ - pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, - uin: request.body.uin, - }); - const queryCommand = new QueryCommand({ - TableName: genericConfig.UserInfoTable, - IndexName: "UinHashIndex", - KeyConditionExpression: "uinHash = :hash", - ExpressionAttributeValues: { - ":hash": { S: uinHash }, - }, - }); - const response = await fastify.dynamoClient.send(queryCommand); - if (!response || !response.Items) { - throw new DatabaseFetchError({ - message: "Failed to retrieve user from database.", - }); - } - if (response.Items.length === 0) { - throw new ValidationError({ - message: - "Failed to find user in database. Please have the user run sync and try again.", - }); - } - if (response.Items.length > 1) { - throw new ValidationError({ - message: - "Multiple users tied to this UIN. This user probably had a NetID change. Please contact support.", - }); - } - const data = unmarshall(response.Items[0]) as { id: string }; - return reply.send({ - email: data.id, - }); + return reply.send( + await getUserIdByUin({ + dynamoClient: fastify.dynamoClient, + uin: request.body.uin, + pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER, + }), + ); }, ); }; diff --git a/src/common/types/generic.ts b/src/common/types/generic.ts index 3a0005ef..f3f22ccc 100644 --- a/src/common/types/generic.ts +++ b/src/common/types/generic.ts @@ -25,6 +25,19 @@ export const illinoisNetId = z id: "IllinoisNetId", }); +export const illinoisUin = z + .string() + .length(9, { message: "UIN must be 9 characters." }) + .regex(/^\d{9}$/i, { + message: "UIN is malformed.", + }) + .meta({ + description: "Valid Illinois UIN.", + example: "627838939", + id: "IllinoisUin", + }); + + export const OrgUniqueId = z.enum(Object.keys(Organizations)).meta({ description: "The unique org ID for a given ACM sub-organization. See https://github.com/acm-uiuc/js-shared/blob/main/src/orgs.ts#L15", examples: ["A01", "C01"], diff --git a/src/common/types/user.ts b/src/common/types/user.ts index 527b56a6..baee5293 100644 --- a/src/common/types/user.ts +++ b/src/common/types/user.ts @@ -1,7 +1,8 @@ import * as z from "zod/v4"; +import { illinoisUin } from "./generic.js"; export const searchUserByUinRequest = z.object({ - uin: z.string().length(9) + uin: illinoisUin }); export const searchUserByUinResponse = z.object({ diff --git a/src/ui/pages/tickets/ScanTickets.page.tsx b/src/ui/pages/tickets/ScanTickets.page.tsx index 51285de6..8f6375fa 100644 --- a/src/ui/pages/tickets/ScanTickets.page.tsx +++ b/src/ui/pages/tickets/ScanTickets.page.tsx @@ -17,9 +17,7 @@ import { } from "@mantine/core"; import { IconAlertCircle, IconCheck, IconCamera } from "@tabler/icons-react"; import jsQR from "jsqr"; -// **MODIFIED**: Added useCallback import React, { useEffect, useState, useRef, useCallback } from "react"; -// **NEW**: Import useSearchParams to manage URL state import { useSearchParams } from "react-router-dom"; import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; @@ -99,21 +97,19 @@ interface TicketItemsResponse { interface ScanTicketsPageProps { getOrganizations?: () => Promise; getTicketItems?: () => Promise; - getPurchasesByEmail?: (email: string) => Promise; + getPurchasesByUin?: (email: string) => Promise; checkInTicket?: ( data: | QRData | { type: string; ticketId?: string; email?: string; stripePi?: string }, ) => Promise; - getEmailFromUIN?: (uin: string) => Promise; } const ScanTicketsPageInternal: React.FC = ({ getOrganizations: getOrganizationsProp, getTicketItems: getTicketItemsProp, - getPurchasesByEmail: getPurchasesByEmailProp, + getPurchasesByUin: getPurchasesByUinProp, checkInTicket: checkInTicketProp, - getEmailFromUIN: getEmailFromUINProp, }) => { // **NEW**: Initialize searchParams hooks const [searchParams, setSearchParams] = useSearchParams(); @@ -180,12 +176,13 @@ const ScanTicketsPageInternal: React.FC = ({ return response.data; }, [api]); - const getPurchasesByEmail = - getPurchasesByEmailProp || + const getPurchasesByUin = + getPurchasesByUinProp || useCallback( - async (email: string) => { - const response = await api.get( - `/api/v1/tickets/purchases/${encodeURIComponent(email)}`, + async (uin: string, productId: string) => { + const response = await api.post( + `/api/v1/tickets/getPurchasesByUser`, + { uin, productId }, ); return response.data; }, @@ -205,33 +202,8 @@ const ScanTicketsPageInternal: React.FC = ({ [api], ); - const getEmailFromUINDefault = useCallback( - async (uin: string): Promise => { - try { - const response = await api.post(`/api/v1/users/findUserByUin`, { uin }); - return response.data.email; - } catch (error: any) { - const samp = new ValidationError({ - message: "Failed to convert UIN to email.", - }); - if ( - error.response?.status === samp.httpStatusCode && - error.response?.data.id === samp.id - ) { - const validationData = error.response.data; - throw new ValidationError(validationData.message || samp.message); - } - throw error; - } - }, - [api], - ); - - const getEmailFromUIN = getEmailFromUINProp || getEmailFromUINDefault; - - // **NEW**: Helper function to get the friendly name const getFriendlyName = (productId: string): string => { - return productNameMap.get(productId) || productId; // Fallback to the ID if not found + return productNameMap.get(productId) || productId; }; const getVideoDevices = async () => { @@ -590,45 +562,29 @@ const ScanTicketsPageInternal: React.FC = ({ setIsLoading(true); setError(""); - let email = inputValue; + let inp = inputValue; // Check if input is from ACM card swiper (format: ACMCARD followed by 4 digits, followed by 9 digits) - if (email.startsWith("ACMCARD")) { - const uinMatch = email.match(/^ACMCARD(\d{4})(\d{9})/); + if (inp.startsWith("ACMCARD")) { + const uinMatch = inp.match(/^ACMCARD(\d{4})(\d{9})/); if (!uinMatch) { setError("Invalid card swipe. Please try again."); setIsLoading(false); setShowModal(true); return; } - email = uinMatch[2]; // Extract the 9-digit UIN + inp = uinMatch[2]; // Extract the 9-digit UIN } // Check if input is UIN (all digits) - if (/^\d+$/.test(email)) { - try { - email = await getEmailFromUIN(email); - } catch (err) { - let errorMessage = - "Failed to convert UIN to email. Please enter NetID or email instead."; - if (err instanceof ValidationError) { - errorMessage = err.message; - } - setError(errorMessage); - setIsLoading(false); - setShowModal(true); - return; - } - } - // Check if input is NetID (no @ symbol) - else if (!email.includes("@")) { - email = `${email}@illinois.edu`; + if (!/^\d{9}$/.test(inp)) { + setError("Invalid input - UIN must be exactly 9 digits."); + setIsLoading(false); + setShowModal(true); + return; } - - // Fetch purchases for this email - const response = await getPurchasesByEmail(email); - - // --- REFACTORED LOGIC --- + // Fetch purchases for this UIN + const response = await getPurchasesByUin(inp, selectedItemFilter); // 1. Get ALL purchases for the selected item, regardless of status. const allPurchasesForItem = [ @@ -872,8 +828,8 @@ const ScanTicketsPageInternal: React.FC = ({ {selectedItemFilter && ( <> setManualInput(e.currentTarget.value)} onKeyDown={(e) => { @@ -895,7 +851,7 @@ const ScanTicketsPageInternal: React.FC = ({ disabled={isLoading || !manualInput.trim()} fullWidth > - Submit Manual Entry + Submit UIN
= ({ {getFriendlyName(scanResult.purchaserData.productId)} )} - - Token ID: {scanResult?.ticketId} - Email: {scanResult?.purchaserData.email} {scanResult.purchaserData.quantity && ( Quantity: {scanResult.purchaserData.quantity} @@ -1136,9 +1089,6 @@ const ScanTicketsPageInternal: React.FC = ({ Status: AVAILABLE - - Ticket ID: {ticket.ticketId} - @@ -1192,9 +1142,6 @@ const ScanTicketsPageInternal: React.FC = ({ Status: {status} - - Ticket ID: {ticket.ticketId} - ); diff --git a/terraform/modules/frontend/main.tf b/terraform/modules/frontend/main.tf index fb96edea..ab8e6e52 100644 --- a/terraform/modules/frontend/main.tf +++ b/terraform/modules/frontend/main.tf @@ -247,6 +247,20 @@ resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { function_arn = aws_cloudfront_function.origin_key_injection.arn } } + ordered_cache_behavior { + path_pattern = "/api/v1/tickets/getPurchasesByUser" + target_origin_id = "HiCpuLambdaFunction-${var.CurrentActiveRegion}" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.no_cache.id + origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" + compress = true + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.origin_key_injection.arn + } + } ordered_cache_behavior { path_pattern = "/api/v1/events*" target_origin_id = "LambdaFunction-${var.CurrentActiveRegion}" diff --git a/tests/unit/functions/tickets.test.ts b/tests/unit/functions/tickets.test.ts index 14b55c99..cc4b0d4e 100644 --- a/tests/unit/functions/tickets.test.ts +++ b/tests/unit/functions/tickets.test.ts @@ -228,6 +228,7 @@ describe("getUserMerchPurchases tests", () => { email: testEmail, productId: "merch-001", quantity: 2, + size: "L" }, refunded: false, fulfilled: true, @@ -240,6 +241,7 @@ describe("getUserMerchPurchases tests", () => { email: testEmail, productId: "merch-002", quantity: 1, + size: "M" }, refunded: true, fulfilled: false, @@ -348,6 +350,7 @@ describe("getUserMerchPurchases tests", () => { email: testEmail, productId: "merch-003", quantity: 1, + size: "S", }, refunded: false, fulfilled: false, diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index 30496c14..c51f8003 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -520,15 +520,31 @@ describe("Test getting all issued tickets", async () => { describe("Test getting user purchases", () => { const testEmail = "test@illinois.edu"; + const testUin = "123456789"; + const testProductId = "event-456"; + + beforeEach(() => { + // Mock the getUserIdByUin function + vi.mock("../../src/api/functions/uin.js", () => ({ + getUserIdByUin: vi.fn(), + getUinHash: vi.fn(), + verifyUiucAccessToken: vi.fn(), + getHashedUserUin: vi.fn(), + saveHashedUserUin: vi.fn(), + })); + }); test("Happy path: get all purchases for a user", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ Items: [ marshall({ ticket_id: "ticket-123", - event_id: "event-456", + event_id: testProductId, payment_method: "stripe", purchase_time: "2024-01-01T00:00:00Z", ticketholder_netid: testEmail, @@ -553,8 +569,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -568,7 +585,7 @@ describe("Test getting user purchases", () => { ticketId: "ticket-123", purchaserData: { email: testEmail, - productId: "event-456", + productId: testProductId, quantity: 1, }, refunded: false, @@ -582,6 +599,7 @@ describe("Test getting user purchases", () => { email: testEmail, productId: "merch-001", quantity: 2, + size: "L", }, refunded: false, fulfilled: true, @@ -589,6 +607,9 @@ describe("Test getting user purchases", () => { }); test("Happy path: user with no purchases", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ Items: [] }) @@ -597,8 +618,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -609,6 +631,9 @@ describe("Test getting user purchases", () => { }); test("Happy path: user with only tickets", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ @@ -628,8 +653,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -638,6 +664,9 @@ describe("Test getting user purchases", () => { }); test("Happy path: user with only merch", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ Items: [] }) @@ -658,8 +687,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -668,6 +698,9 @@ describe("Test getting user purchases", () => { }); test("Happy path: user with multiple purchases of each type", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ @@ -716,8 +749,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); const responseDataJson = response.body; expect(response.statusCode).toEqual(200); @@ -725,32 +759,93 @@ describe("Test getting user purchases", () => { expect(responseDataJson.merch).toHaveLength(2); }); - test("Sad path: invalid email format", async () => { - const invalidEmail = "not-an-email"; + test("Happy path: productId filtering returns only matching items", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + + const filterProductId = "event-specific"; + + ddbMock + .on(QueryCommand) + .resolvesOnce({ + Items: [ + marshall({ + ticket_id: "ticket-filtered", + event_id: filterProductId, + payment_method: "stripe", + purchase_time: "2024-01-01T00:00:00Z", + ticketholder_netid: testEmail, + used: false, + }), + ], + }) + .resolvesOnce({ + Items: [ + marshall({ + stripe_pi: "pi_filtered", + email: testEmail, + fulfilled: false, + item_id: filterProductId, + quantity: 1, + refunded: false, + size: "M", + }), + ], + }); + const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${invalidEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: filterProductId }); + + const responseDataJson = response.body; + expect(response.statusCode).toEqual(200); + expect(responseDataJson.tickets).toHaveLength(1); + expect(responseDataJson.merch).toHaveLength(1); + expect(responseDataJson.tickets[0].purchaserData.productId).toEqual( + filterProductId, + ); + expect(responseDataJson.merch[0].purchaserData.productId).toEqual( + filterProductId, + ); + }); + + test("Sad path: invalid UIN format", async () => { + const invalidUin = "not-a-uin"; + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: invalidUin, productId: testProductId }); expect(response.statusCode).toEqual(400); expect(response.body).toHaveProperty("error"); }); test("Sad path: database error on tickets query", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock.on(QueryCommand).rejectsOnce(new Error("Database error")); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); expect(response.statusCode).toEqual(500); expect(response.body).toHaveProperty("error"); }); test("Sad path: database error on merch query", async () => { + const { getUserIdByUin } = await import("../../src/api/functions/uin.js"); + vi.mocked(getUserIdByUin).mockResolvedValueOnce({ id: testEmail }); + ddbMock .on(QueryCommand) .resolvesOnce({ Items: [] }) @@ -759,8 +854,9 @@ describe("Test getting user purchases", () => { const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) - .get(`/api/v1/tickets/purchases/${testEmail}`) - .set("authorization", `Bearer ${testJwt}`); + .post("/api/v1/tickets/getPurchasesByUser") + .set("authorization", `Bearer ${testJwt}`) + .send({ uin: testUin, productId: testProductId }); expect(response.statusCode).toEqual(500); expect(response.body).toHaveProperty("error");