From 40e770276ba9b5859ac66433ae3a61e235845f0b Mon Sep 17 00:00:00 2001 From: Amormio Velasquez III Date: Wed, 3 Jun 2026 21:14:07 -0700 Subject: [PATCH] fix: email validation and passing tests --- app/api/listings/[id]/route.ts | 20 +++++++------ app/api/listings/__tests__/route.test.ts | 38 +++++++++++++----------- app/api/listings/route.ts | 15 +++------- app/listings/[id]/page.tsx | 24 ++++++++------- components/listings/ContactModal.tsx | 29 ++++++++++++++---- models/Listing.ts | 1 + 6 files changed, 74 insertions(+), 53 deletions(-) diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index b837b61..028bb60 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -43,7 +43,10 @@ const listingValidationSchema = z * ex req: GET /listings/001 HTTP/1.1 * @returns the listing as a JS object in a JSON response */ -async function GET(request: Request, { params }: { params: { id: string } }) { +type ListingRouteContext = { params: Promise<{ id: string }> }; + +async function GET(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:view"); if (!allowed) { return NextResponse.json( @@ -64,7 +67,7 @@ async function GET(request: Request, { params }: { params: { id: string } }) { ); } - const parsedId = objectIdSchema.safeParse(params.id); + const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( { @@ -97,7 +100,8 @@ async function GET(request: Request, { params }: { params: { id: string } }) { * @param id the ID of the listing to get as part of the path params * @returns the updated listing as a JS object in a JSON response */ -async function PUT(request: Request, { params }: { params: { id: string } }) { +async function PUT(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:update"); if (!allowed) { return NextResponse.json( @@ -118,7 +122,7 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { ); } - const parsedId = objectIdSchema.safeParse(params.id); + const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( { @@ -214,10 +218,8 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { * @param id the ID of the listing to get as part of the path params * @returns JSON response signaling the success of the listing deletion */ -async function DELETE( - request: Request, - { params }: { params: { id: string } } -) { +async function DELETE(request: Request, context: ListingRouteContext) { + const { id } = await context.params; const { allowed, reason } = await getSession("inventory:delete"); if (!allowed) { return NextResponse.json( @@ -238,7 +240,7 @@ async function DELETE( ); } - const parsedId = objectIdSchema.safeParse(params.id); + const parsedId = objectIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( { diff --git a/app/api/listings/__tests__/route.test.ts b/app/api/listings/__tests__/route.test.ts index a2828b9..e1eeda4 100644 --- a/app/api/listings/__tests__/route.test.ts +++ b/app/api/listings/__tests__/route.test.ts @@ -22,11 +22,13 @@ jest.mock("@/lib/googleCloud", () => ({ jest.mock("@/lib/rbac", () => ({ getSession: jest.fn().mockResolvedValue({ allowed: true, - user: null, + user: { email: "seller@ucsd.edu" }, reason: undefined, }), })); +const routeParams = (id: string) => ({ params: Promise.resolve({ id }) }); + /** import after mocking */ import { GET, POST } from "@/app/api/listings/route"; import { GET as GET_BY_ID, PUT, DELETE } from "@/app/api/listings/[id]/route"; @@ -127,7 +129,7 @@ describe("API: Successful Responses", () => { (getListing as jest.Mock).mockResolvedValue(listingData); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toEqual(200); @@ -212,6 +214,7 @@ describe("API: Successful Responses", () => { price: 50, expiryDate: new Date(listingData.expiryDate), hazardTags: ["Physical", "Chemical"], + sellerEmail: "seller@ucsd.edu", }) ); }); @@ -264,6 +267,7 @@ describe("API: Successful Responses", () => { expect(addListing).toHaveBeenCalledWith( expect.objectContaining({ imageUrls: [], + sellerEmail: "seller@ucsd.edu", }) ); }); @@ -371,7 +375,7 @@ describe("API: Successful Responses", () => { body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(200); @@ -403,7 +407,7 @@ describe("API: Successful Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(200); @@ -464,7 +468,7 @@ describe("API: Error Responses", () => { (connectToDatabase as jest.Mock).mockRejectedValue(new Error("DB Error")); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -478,7 +482,7 @@ describe("API: Error Responses", () => { (connectToDatabase as jest.Mock).mockResolvedValue({}); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -495,7 +499,7 @@ describe("API: Error Responses", () => { (getListing as jest.Mock).mockResolvedValue(null); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -511,7 +515,7 @@ describe("API: Error Responses", () => { (getListing as jest.Mock).mockRejectedValue(new Error("DB Error")); const req = new Request(`http://localhost/api/listings/${id}`); - const res = await GET_BY_ID(req, { params: { id: id } }); + const res = await GET_BY_ID(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -621,7 +625,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -643,7 +647,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -665,7 +669,7 @@ describe("API: Error Responses", () => { body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -688,7 +692,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -717,7 +721,7 @@ describe("API: Error Responses", () => { method: "PUT", body: formData, }); - const res = await PUT(req, { params: { id } }); + const res = await PUT(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -741,7 +745,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); @@ -757,7 +761,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(400); @@ -776,7 +780,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(404); @@ -794,7 +798,7 @@ describe("API: Error Responses", () => { const req = new Request(`http://localhost/api/listings/${id}`, { method: "DELETE", }); - const res = await DELETE(req, { params: { id } }); + const res = await DELETE(req, routeParams(id)); const body = await res.json(); expect(res.status).toBe(500); diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index f541519..5b59671 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -114,18 +114,10 @@ async function GET(request: Request) { * @returns JSON response with success message and req body echoed */ async function POST(request: Request) { - let { allowed, reason } = await getSession("listing:create"); - if (!allowed) { - return NextResponse.json( - { success: false, message: reason }, - { status: 403 } - ); - } - - ({ allowed, reason } = await getSession("listing:create")); - if (!allowed) { + const { allowed, reason, user } = await getSession("listing:create"); + if (!allowed || !user?.email) { return NextResponse.json( - { success: false, message: reason }, + { success: false, message: reason ?? "Unauthenticated" }, { status: 403 } ); } @@ -189,6 +181,7 @@ async function POST(request: Request) { const listingData = { ...parsedBody.data, imageUrls, + sellerEmail: user.email, createdAt: new Date(), } as ListingInput; const listing = await addListing(listingData); diff --git a/app/listings/[id]/page.tsx b/app/listings/[id]/page.tsx index d9cdeee..13df374 100644 --- a/app/listings/[id]/page.tsx +++ b/app/listings/[id]/page.tsx @@ -10,16 +10,20 @@ interface ListingPageProps { }>; } -/** - * Resolve a contact email for the listing until proper transaction - * functionality exists. - */ -function getListingContactEmail() { - return ( +function isUsableEmail(email: string) { + return email.includes("@") && email.includes("."); +} + +function getContactEmailForListing(listing: Listing) { + const seller = listing.sellerEmail?.trim() ?? ""; + if (seller && isUsableEmail(seller)) { + return seller; + } + const fallback = process.env.NEXT_PUBLIC_MARKETPLACE_CONTACT_EMAIL ?? process.env.MARKETPLACE_CONTACT_EMAIL ?? - "marketplace@lab.example" - ); + ""; + return fallback.trim(); } /** @@ -57,7 +61,7 @@ export default async function ListingPage({ params }: ListingPageProps) { return ( ); @@ -66,7 +70,7 @@ export default async function ListingPage({ params }: ListingPageProps) { return ( ); diff --git a/components/listings/ContactModal.tsx b/components/listings/ContactModal.tsx index 73186a8..b684b8f 100644 --- a/components/listings/ContactModal.tsx +++ b/components/listings/ContactModal.tsx @@ -90,6 +90,9 @@ export function ContactModal({ return null; } + const hasValidEmail = + contactEmail.includes("@") && contactEmail.includes("."); + return (
- - {contactEmail} - + {hasValidEmail ? ( + + {contactEmail} + + ) : ( +

Seller email is not available.

+ )}
- - Email Seller - + {hasValidEmail ? ( + + Email Seller + + ) : ( + + )}
diff --git a/models/Listing.ts b/models/Listing.ts index 21455a3..53e2e1d 100644 --- a/models/Listing.ts +++ b/models/Listing.ts @@ -38,6 +38,7 @@ const listingSchema = new Schema( enum: ["Physical", "Chemical", "Biological", "Other"], }, ], + sellerEmail: { type: String, trim: true, lowercase: true }, }, { toJSON: {