diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index f9e552b..b837b61 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -7,7 +7,10 @@ import { updateListing, } from "@/services/listings/listings"; import { ListingInput } from "@/models/Listing"; -import { uploadImage } from "@/lib/googleCloud"; +import { + getValidImageFiles, + uploadListingImages, +} from "@/lib/listing-images"; import { getSession } from "@/lib/rbac"; const objectIdSchema = z @@ -127,9 +130,13 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { } const formData = await request.formData(); - const updateData: Partial = { - ...Object.fromEntries(formData.entries()), - }; + // Build a plain object from non-file fields before validation. + const textEntries: [string, FormDataEntryValue][] = []; + for (const [key, value] of formData.entries()) { + if (key === "image" || key === "images" || key === "hazardTags") continue; + textEntries.push([key, value]); + } + const updateData: Partial = Object.fromEntries(textEntries); // handle array fields const hazardTags = formData.getAll("hazardTags"); @@ -152,9 +159,6 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { ); } - // handle image uploads if provided - const imageFiles = formData.getAll("images") as File[]; - const parsedRequest = listingValidationSchema.safeParse(updateData); if (!parsedRequest.success) { return NextResponse.json( @@ -166,23 +170,15 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { ); } - if (imageFiles.length > 0) { - const imageUrls: string[] = []; - - for (const imageFile of imageFiles) { - const buffer = Buffer.from(await imageFile.arrayBuffer()); - const imageUrl = await uploadImage(buffer, imageFile.name); - imageUrls.push(imageUrl); + try { + // Only replace image URLs when real files were uploaded in this request. + const updatePayload = { ...parsedRequest.data }; + const imageFiles = getValidImageFiles(formData); + if (imageFiles.length > 0) { + updatePayload.imageUrls = await uploadListingImages(imageFiles); } - updateData.imageUrls = imageUrls; - } - - try { - const updatedListing = await updateListing( - parsedId.data, - parsedRequest.data - ); + const updatedListing = await updateListing(parsedId.data, updatePayload); if (!updatedListing) { return NextResponse.json( diff --git a/app/api/listings/__tests__/route.test.ts b/app/api/listings/__tests__/route.test.ts index 92421ef..a2828b9 100644 --- a/app/api/listings/__tests__/route.test.ts +++ b/app/api/listings/__tests__/route.test.ts @@ -167,7 +167,7 @@ describe("API: Successful Responses", () => { const formData = new FormData(); Object.entries(listingData).forEach(([key, value]) => { if (Array.isArray(value)) { - value.forEach((item) => formData.append(key, item)); + value.forEach(item => formData.append(key, item)); } else { formData.append(key, String(value)); } @@ -216,6 +216,58 @@ describe("API: Successful Responses", () => { ); }); + test("creates listing when form includes an empty images part", async () => { + const date = new Date(); + const minimalData = { + itemName: "Flask", + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + condition: "New", + }; + + const mockReturnedData = { + ...minimalData, + id: "123", + createdAt: date, + imageUrls: [], + labName: "", + labLocation: "", + description: "", + price: 0, + hazardTags: [], + }; + + const formData = new FormData(); + formData.append( + "images", + new File([], "", { type: "application/octet-stream" }) + ); + Object.entries(minimalData).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (addListing as jest.Mock).mockResolvedValue(mockReturnedData); + + const req = new Request("http://localhost/api/listings", { + method: "POST", + body: formData, + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.success).toBe(true); + expect(addListing).toHaveBeenCalledWith( + expect.objectContaining({ + imageUrls: [], + }) + ); + }); + test("creates listing with defaults for optional fields", async () => { const date = new Date(); @@ -305,7 +357,7 @@ describe("API: Successful Responses", () => { Object.entries(listingData).forEach(([key, value]) => { if (value === null || value === undefined) return; if (Array.isArray(value)) { - value.forEach((item) => formData.append(key, item)); + value.forEach(item => formData.append(key, item)); } else { formData.append(key, String(value)); } diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 09e030d..f541519 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -3,7 +3,10 @@ import { connectToDatabase } from "@/lib/mongoose"; import { z } from "zod"; import { getFilteredListings, addListing } from "@/services/listings/listings"; import { ListingInput } from "@/models/Listing"; -import { uploadImage } from "@/lib/googleCloud"; +import { + getValidImageFiles, + uploadListingImages, +} from "@/lib/listing-images"; import { getSession } from "@/lib/rbac"; const listingValidationSchema = z.object({ @@ -143,7 +146,7 @@ async function POST(request: Request) { const hazardTags: string[] = []; for (const [key, value] of entries) { - if (key === "image") continue; + if (key === "image" || key === "images") continue; if (key === "hazardTags") { hazardTags.push(value as string); } else { @@ -164,8 +167,6 @@ async function POST(request: Request) { result.expiryDate = new Date(result.expiryDate as unknown as string); } - const imageFiles = formData.getAll("images") as File[]; - const parsedBody = listingValidationSchema.safeParse(result); if (!parsedBody.success) { return NextResponse.json( @@ -177,19 +178,17 @@ async function POST(request: Request) { ); } - if (imageFiles.length > 0) { - const imageUrls: string[] = []; - for (const imageFile of imageFiles) { - const buffer = Buffer.from(await imageFile.arrayBuffer()); - const imageUrl = await uploadImage(buffer, imageFile.name); - imageUrls.push(imageUrl); + try { + // Keep parsed defaults unless real image files were provided. + let imageUrls = parsedBody.data.imageUrls ?? []; + const imageFiles = getValidImageFiles(formData); + if (imageFiles.length > 0) { + imageUrls = await uploadListingImages(imageFiles); } - result.imageUrls = imageUrls; - } - try { const listingData = { ...parsedBody.data, + imageUrls, createdAt: new Date(), } as ListingInput; const listing = await addListing(listingData); diff --git a/components/listings/ListingForm.tsx b/components/listings/ListingForm.tsx index aee23b7..7be2545 100644 --- a/components/listings/ListingForm.tsx +++ b/components/listings/ListingForm.tsx @@ -37,14 +37,29 @@ export function ListingForm({ initialValues, listingId }: ListingFormProps) { ); } + /** + * Submits listing form data while omitting empty file inputs. + * This prevents browsers from sending a blank `images` part that could + * trigger unnecessary image-upload logic on the API. + */ async function handleSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); setErrorMessage(null); - const formData = new FormData(event.currentTarget); + const form = event.currentTarget; + const formData = new FormData(form); + formData.delete("images"); formData.delete("hazardTags"); + const fileInput = form.elements.namedItem("images") as HTMLInputElement | null; + if (fileInput?.files) { + Array.from(fileInput.files).forEach((file) => { + if (file.size > 0) { + formData.append("images", file); + } + }); + } hazards.forEach((tag) => formData.append("hazardTags", tag)); const endpoint = isEditMode ? `/api/listings/${listingId}` : "/api/listings"; diff --git a/lib/__tests__/listing-images.test.ts b/lib/__tests__/listing-images.test.ts new file mode 100644 index 0000000..77eda81 --- /dev/null +++ b/lib/__tests__/listing-images.test.ts @@ -0,0 +1,23 @@ +import { getValidImageFiles } from "@/lib/listing-form-data"; + +describe("getValidImageFiles", () => { + it("ignores empty file parts from the browser", () => { + const formData = new FormData(); + formData.append("images", new File([], "", { type: "application/octet-stream" })); + formData.append("itemName", "test"); + + expect(getValidImageFiles(formData)).toHaveLength(0); + }); + + it("keeps files with content and a name", () => { + const formData = new FormData(); + formData.append( + "images", + new File([new Uint8Array([1])], "photo.png", { type: "image/png" }) + ); + + const files = getValidImageFiles(formData); + expect(files).toHaveLength(1); + expect(files[0].name).toBe("photo.png"); + }); +}); diff --git a/lib/googleCloud.ts b/lib/googleCloud.ts index 8f2a3cd..5af6016 100644 --- a/lib/googleCloud.ts +++ b/lib/googleCloud.ts @@ -15,29 +15,42 @@ const bucketName = process.env.GOOGLE_CLOUD_BUCKET_NAME!; const bucket = storage.bucket(bucketName); /** - * Upload a publicly accessible image file to GCS - * @param file image as raw bytes of data - * @param originalFilename original name of the image file - * @returns a promise that resolves to the public URL of the uploaded image + * Infers a MIME type from filename extension for object metadata. + * Defaults to JPEG when extension is missing or unknown. + */ +function inferContentType(filename: string): string { + const lower = filename.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".gif")) return "image/gif"; + return "image/jpeg"; +} + +/** + * Upload an image file to GCS. + * Public access is expected via bucket-level IAM (uniform bucket-level access); + * do not call per-object ACL APIs such as makePublic(). */ export async function uploadImage( file: Buffer, originalFilename: string ): Promise { + if (file.length === 0) { + throw new Error("Cannot upload an empty file."); + } + const timestamp = Date.now(); - const safeFilename = originalFilename.replace(/\s/g, "_"); // replace spaces - const uniqueFilename = `listings/${timestamp}-${safeFilename}`; // unique by timestamp + const safeFilename = + originalFilename.replace(/\s/g, "_").trim() || "image"; + const uniqueFilename = `listings/${timestamp}-${safeFilename}`; const blob = bucket.file(uniqueFilename); await blob.save(file, { metadata: { - contentType: "image/jpeg", + contentType: inferContentType(safeFilename), }, }); - // Make the file publicly accessible - await blob.makePublic(); - return `https://storage.googleapis.com/${bucketName}/${uniqueFilename}`; } diff --git a/lib/listing-form-data.ts b/lib/listing-form-data.ts new file mode 100644 index 0000000..ea178cc --- /dev/null +++ b/lib/listing-form-data.ts @@ -0,0 +1,11 @@ +/** + * Returns only real uploaded image files from a multipart form payload. + * Browsers may include an empty `images` part (filename="", size=0) even when + * the user did not pick a file, so those entries are filtered out. + * @param formData the data payload + */ +export function getValidImageFiles(formData: FormData): File[] { + return (formData.getAll("images") as File[]).filter( + file => file.size > 0 && file.name.trim() !== "" + ); +} diff --git a/lib/listing-images.ts b/lib/listing-images.ts new file mode 100644 index 0000000..12c7ebe --- /dev/null +++ b/lib/listing-images.ts @@ -0,0 +1,17 @@ +import { uploadImage } from "@/lib/googleCloud"; + +export { getValidImageFiles } from "@/lib/listing-form-data"; + +/** + * Uploads a list of validated browser `File` objects and returns their public + * URLs in the same order they were provided. + * @param files the files to upload + */ +export async function uploadListingImages(files: File[]): Promise { + const imageUrls: string[] = []; + for (const imageFile of files) { + const buffer = Buffer.from(await imageFile.arrayBuffer()); + imageUrls.push(await uploadImage(buffer, imageFile.name)); + } + return imageUrls; +}