diff --git a/.gitignore b/.gitignore index 2b24613..319f118 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ .next/ .env.local .DS_Store -.env \ No newline at end of file +.env +coverage/ \ No newline at end of file diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts new file mode 100644 index 0000000..f295041 --- /dev/null +++ b/app/api/listings/[id]/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server"; +import { connectToDatabase } from "@/lib/mongoose"; +import { z } from "zod"; +import { + deleteListing, + getListing, + updateListing, +} from "@/services/listings/listings"; + +/* IMPORTANT: implement user auth in future (e.g. only lab admins create/delete) */ +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); +const listingValidationSchema = z.object({ + // handle defaults here for the optional fields + itemName: z.string(), + itemId: z.string(), + labName: z.string().optional().default(""), + labLocation: z.string().optional().default(""), + labId: z.string(), + imageUrls: z.array(z.string()).optional().default([]), + quantityAvailable: z.number(), + expiryDate: z.date().optional(), + description: z.string().optional().default(""), + price: z.number().optional().default(0), + status: z.enum(["ACTIVE", "INACTIVE"]), + condition: z.enum(["New", "Good", "Fair", "Poor"]), + hazardTags: z + .array(z.enum(["Physical", "Chemical", "Biological", "Other"])) + .optional() + .default([]), +}); + +/** + * Get a listing entry by ID + * @param id the ID of the listing to get + * 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 } }) { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json( + { + success: false, + message: "Invalid ID format. Must be a valid MongoDB ObjectId.", + }, + { status: 400 } + ); + } + + try { + const listing = await getListing(parsedId.data); // don't need mongo doc features + if (!listing) { + return NextResponse.json( + { success: false, message: "Listing not found." }, + { status: 404 } + ); + } + return NextResponse.json({ success: true, data: listing }, { status: 200 }); + } catch { + return NextResponse.json( + { success: false, message: "Error occurred while retrieving listing." }, + { status: 500 } + ); + } +} + +/** + * Update a listing entry by ID + * @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 } }) { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json( + { + success: false, + message: "Invalid ID format. Must be a valid MongoDB ObjectId.", + }, + { status: 400 } + ); + } + + const body = await request.json(); + + const validator = z.object({ + id: objectIdSchema, + update: listingValidationSchema.partial(), + }); + + const parsedRequest = validator.safeParse({ + id: parsedId.data, + update: body, + }); + if (!parsedRequest.success) { + return NextResponse.json( + { + success: false, + message: "Invalid request body.", + }, + { status: 400 } + ); + } + + try { + const updatedListing = await updateListing( + parsedId.data, + parsedRequest.data.update + ); + if (!updatedListing) { + return NextResponse.json( + { + success: false, + message: "Listing not found", + }, + { status: 404 } + ); + } + return NextResponse.json( + { + success: true, + data: updatedListing, + message: "Listing successfully updated.", + }, + { status: 200 } + ); + } catch { + return NextResponse.json( + { + success: false, + message: "Error occurred while updating listing.", + }, + { status: 500 } + ); + } +} + +/** + * Delete a listing entry by ID + * @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 } } +) { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + const parsedId = objectIdSchema.safeParse(params.id); + if (!parsedId.success) { + return NextResponse.json( + { + success: false, + message: "Invalid ID format. Must be a valid MongoDB ObjectId.", + }, + { status: 400 } + ); + } + + try { + const listing = await deleteListing(parsedId.data); + if (!listing) { + return NextResponse.json( + { + success: false, + message: "Listing not found", + }, + { status: 404 } + ); + } + return NextResponse.json( + { + success: true, + message: "Listing successfully deleted.", + }, + { status: 204 } + ); + } catch { + return NextResponse.json( + { + success: false, + message: "Error occurred while deleting listing.", + }, + { status: 500 } + ); + } +} + +export { GET, PUT, DELETE }; diff --git a/app/api/listings/__tests__/route.test.ts b/app/api/listings/__tests__/route.test.ts new file mode 100644 index 0000000..8a82164 --- /dev/null +++ b/app/api/listings/__tests__/route.test.ts @@ -0,0 +1,251 @@ +import mongoose from "mongoose"; +import { GET, POST } from "@/app/api/listings/route"; +import { GET as GET_BY_ID, PUT, DELETE } from "@/app/api/listings/[id]/route"; +import { connectToDatabase } from "@/lib/mongoose"; + +/** test route handler, mock db connection and svc handlers */ +jest.mock("@/lib/mongoose", () => ({ + connectToDatabase: jest.fn(), +})); + +jest.mock("@/services/listings/listings", () => ({ + getListings: jest.fn(), + getFilteredListings: jest.fn(), + getListing: jest.fn(), + addListing: jest.fn(), + updateListing: jest.fn(), + deleteListing: jest.fn(), +})); + +/** import after mocking */ +import { + getListings, + getFilteredListings, + getListing, + addListing, + updateListing, + deleteListing, +} from "@/services/listings/listings"; + +describe("API: Successful Responses", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("GET /listings", () => { + test("returns filtered listings successfully", async () => { + // arrange + const date = new Date().toISOString(); + const id = "123"; + + const listingData = { + id: id, // just keep as string now since res.json() stringifies + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (getFilteredListings as jest.Mock).mockResolvedValue({ + listings: [listingData], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, + }); + + // act + const req = new Request("http://localhost/api/listings"); + const res = await GET(req); + const body = await res.json(); + + // assert + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual([listingData]); + expect(body.pagination).toEqual({ + page: 1, + limit: 10, + total: 1, + totalPages: 1, + }); + expect(getFilteredListings).toHaveBeenCalledWith({ + labId: undefined, + itemId: undefined, + page: 1, // default + limit: 10, // default + }); + }); + }); + + describe("GET /listings/[id]", () => { + test("returns a specific listing successfully", async () => { + const date = new Date().toISOString(); + const id = new mongoose.Types.ObjectId().toString(); + + const listingData = { + id: id, + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (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 body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.success).toEqual(true); + expect(body.data).toEqual(listingData); + }); + }); + + describe("POST /listings", () => { + test("creates a new listing successfully", async () => {}); + }); + + describe("PUT /listings/[id]", () => { + test("updates a listing successfully", async () => {}); + }); + + describe("DELETE /listings/[id]", () => { + test("deletes a listing successfully", async () => {}); + }); +}); + +describe("API: Error Responses", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("GET /listings", () => { + test("DB connection error", async () => { + (connectToDatabase as jest.Mock).mockRejectedValue(new Error("DB Error")); + + const req = new Request("http://localhost/api/listings"); + const res = await GET(req); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.success).toEqual(false); + expect(body.message).toEqual("Error connecting to database."); + }); + + test("service error retrieving listings", async () => { + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (getFilteredListings as jest.Mock).mockRejectedValue( + new Error("DB Error") + ); + + const req = new Request("http://localhost/api/listings"); + const res = await GET(req); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.success).toEqual(false); + expect(body.message).toEqual("Error occurred while retrieving listings."); + expect(getFilteredListings).toHaveBeenCalledWith({ + labId: undefined, + itemId: undefined, + page: 1, + limit: 10, + }); + }); + }); + + describe("GET /listings/[id]", () => { + test("DB connection error", async () => { + const id = new mongoose.Types.ObjectId().toString(); + + (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 body = await res.json(); + + expect(res.status).toBe(500); + expect(body.success).toEqual(false); + expect(body.message).toEqual("Error connecting to database."); + }); + + test("invalid id format", async () => { + const id = "123"; + + (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 body = await res.json(); + + expect(res.status).toBe(400); + expect(body.success).toEqual(false); + expect(body.message).toEqual( + "Invalid ID format. Must be a valid MongoDB ObjectId." + ); + }); + + test("listing not found", async () => { + const id = new mongoose.Types.ObjectId().toString(); + + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (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 body = await res.json(); + + expect(res.status).toBe(404); + expect(body.success).toEqual(false); + expect(body.message).toEqual("Listing not found."); + expect(getListing).toHaveBeenCalledWith(id); + }); + + test("service error retrieving listing", async () => { + const id = new mongoose.Types.ObjectId().toString(); + + (connectToDatabase as jest.Mock).mockResolvedValue({}); + (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 body = await res.json(); + + expect(res.status).toBe(500); + expect(body.success).toEqual(false); + expect(body.message).toEqual("Error occurred while retrieving listing."); + expect(getListing).toHaveBeenCalledWith(id); + }); + }); + + describe("POST /listings", () => { + test("DB connection error", async () => {}); + test("invalid req body", async () => {}); + test("listing already exists", async () => {}); + test("service error creating listing", async () => {}); + }); + + describe("PUT /listings/[id]", () => { + test("DB connection error", async () => {}); + test("invalid id format", async () => {}); + test("invalid req body", async () => {}); + test("listing not found", async () => {}); + test("service error updating listing", async () => {}); + }); + + describe("DELETE /listings/[id]", () => { + test("DB connection error", async () => {}); + test("invalid id format", async () => {}); + test("listing not found", async () => {}); + test("service error deleting listing", async () => {}); + }); +}); diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts new file mode 100644 index 0000000..803882a --- /dev/null +++ b/app/api/listings/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; +import { connectToDatabase } from "@/lib/mongoose"; +import { z } from "zod"; +import { getFilteredListings, addListing } from "@/services/listings/listings"; +import { ListingInput } from "@/models/Listing"; + +const listingValidationSchema = z.object({ + // handle defaults here for the optional fields + itemName: z.string(), + itemId: z.string(), + labName: z.string().optional().default(""), + labLocation: z.string().optional().default(""), + labId: z.string(), + imageUrls: z.array(z.string()).optional().default([]), + quantityAvailable: z.number(), + expiryDate: z.date().optional(), + description: z.string().optional().default(""), + price: z.number().optional().default(0), + status: z.enum(["ACTIVE", "INACTIVE"]), + condition: z.enum(["New", "Good", "Fair", "Poor"]), + hazardTags: z + .array(z.enum(["Physical", "Chemical", "Biological", "Other"])) + .optional() + .default([]), +}); + +/** + * Get filtered listings stored in db + * @param request the request + * ex req: GET /listings/?labId=3&page=2&limit=5 HTTP/1.1 + * @returns JSON response with the filtered listings as JS objects + */ +async function GET(request: Request) { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + const { searchParams } = new URL(request.url); + const labId = searchParams.get("labId") || undefined; + const itemId = searchParams.get("itemId") || undefined; + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + try { + const { listings, pagination } = await getFilteredListings({ + labId, + itemId, + page, + limit, + }); + + return NextResponse.json( + { + success: true, + data: listings, + pagination, + }, + { status: 200 } + ); + } catch { + return NextResponse.json( + { success: false, message: "Error occurred while retrieving listings." }, + { status: 500 } + ); + } +} + +/** + * Create a new listing to store in db + * @param request the request with JSON data in req body + * @returns JSON response with success message and req body echoed + */ +async function POST(request: Request) { + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + // assuming frontend sends req with content-type set to app/json + // content type automatically set as app/json + const body = await request.json(); + const parsedBody = listingValidationSchema.safeParse(body); + + if (!parsedBody.success) { + return NextResponse.json( + { + success: false, + message: "Invalid request body.", + }, + { status: 400 } + ); + } + + try { + const listingData = { + ...parsedBody.data, + createdAt: new Date(), + } as ListingInput; + const listing = await addListing(listingData); + return NextResponse.json( + { + success: true, + message: "Successfully created new listing.", + data: listing, + }, + { status: 201, headers: { Location: `/app/listings/${listing.id}` } } + ); + } catch (error: any) { + if (error.code === 11000) { + return NextResponse.json( + // don't send mongo's error - exposes design info + { success: false, message: "This listing already exists." }, + { status: 409 } + ); + } + return NextResponse.json( + { success: false, message: "Error occurred while creating new listing." }, + { status: 500 } + ); + } +} + +export { GET, POST }; diff --git a/jest.config.ts b/jest.config.ts index 6a2f4c6..15c5c16 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,53 +1,49 @@ -import type { Config } from 'jest'; -import path from 'path'; +import type { Config } from "jest"; +import path from "path"; const config: Config = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: [''], + preset: "ts-jest", + testEnvironment: "node", + roots: [""], // Test file patterns testMatch: [ - '**/__tests__/**/*.test.ts', - '**/__tests__/**/*.spec.ts', - '**/.test.ts', - '**/.spec.ts', + "**/__tests__/**/*.test.ts", + "**/__tests__/**/*.spec.ts", + "**/.test.ts", + "**/.spec.ts", ], // Module path mapping (for @/ imports) moduleNameMapper: { - '^@/(.*)$': '/$1', + "^@/(.*)$": "/$1", }, // Setup files - setupFilesAfterEnv: ['/jest.setup.ts'], + setupFilesAfterEnv: ["/jest.setup.ts"], // Coverage settings collectCoverageFrom: [ - 'app/*/.ts', - 'app/*/.tsx', - 'services/*/.ts', - 'models/*/.ts', - 'lib/*/.ts', - '!*/.d.ts', - '!*/node_modules/*', - '!*/.next/*', - '!*/coverage/*', + "app/*/.ts", + "app/*/.tsx", + "services/*/.ts", + "models/*/.ts", + "lib/*/.ts", + "!*/.d.ts", + "!*/node_modules/*", + "!*/.next/*", + "!*/coverage/*", ], - coveragePathIgnorePatterns: [ - '/node_modules/', - '/.next/', - '/coverage/', - ], + coveragePathIgnorePatterns: ["/node_modules/", "/.next/", "/coverage/"], // Transform files transform: { - '^.+\\.tsx?$': [ - 'ts-jest', + "^.+\\.tsx?$": [ + "ts-jest", { tsconfig: { - jsx: 'react', + jsx: "react", esModuleInterop: true, }, }, @@ -55,7 +51,7 @@ const config: Config = { }, // Module file extensions - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], // Test timeout (important for DB tests) testTimeout: 10000, @@ -64,4 +60,4 @@ const config: Config = { verbose: true, }; -export default config; \ No newline at end of file +export default config; diff --git a/lib/__tests__/mongoose.test.ts b/lib/__tests__/mongoose.test.ts index a05922f..d6b2ed5 100644 --- a/lib/__tests__/mongoose.test.ts +++ b/lib/__tests__/mongoose.test.ts @@ -1,7 +1,7 @@ -import { connectToDatabase, disconnectDatabase } from '@/lib/mongoose'; -import mongoose from 'mongoose'; +import { connectToDatabase, disconnectDatabase } from "@/lib/mongoose"; +import mongoose from "mongoose"; -describe('Database Connection (Singleton)', () => { +describe("Database Connection (Singleton)", () => { beforeEach(async () => { // Reset global mongoose state global.mongoose = { conn: null, promise: null }; @@ -11,14 +11,14 @@ describe('Database Connection (Singleton)', () => { await disconnectDatabase(); }); - it('should establish a database connection', async () => { + it("should establish a database connection", async () => { const connection = await connectToDatabase(); expect(connection).toBeDefined(); expect(mongoose.connection.readyState).toBe(1); // 1 = connected }); - it('should return the same connection on multiple calls', async () => { + it("should return the same connection on multiple calls", async () => { const conn1 = await connectToDatabase(); const conn2 = await connectToDatabase(); const conn3 = await connectToDatabase(); @@ -27,7 +27,7 @@ describe('Database Connection (Singleton)', () => { expect(conn2).toBe(conn3); }); - it('should handle concurrent connection requests', async () => { + it("should handle concurrent connection requests", async () => { const promises = Array(10) .fill(null) .map(() => connectToDatabase()); @@ -38,7 +38,7 @@ describe('Database Connection (Singleton)', () => { expect(uniqueConnections.size).toBe(1); }); - it('should reconnect after disconnection', async () => { + it("should reconnect after disconnection", async () => { const conn1 = await connectToDatabase(); expect(mongoose.connection.readyState).toBe(1); @@ -48,4 +48,4 @@ describe('Database Connection (Singleton)', () => { const conn2 = await connectToDatabase(); expect(mongoose.connection.readyState).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/lib/mongoose.ts b/lib/mongoose.ts index 988ab74..3a2b16f 100644 --- a/lib/mongoose.ts +++ b/lib/mongoose.ts @@ -1,12 +1,13 @@ import mongoose from "mongoose"; -const MONGODB_URI = process.env.DATABASE_URL!; // assert that db_url is not null +// const MONGODB_URI = process.env.DATABASE_URL!; // assert that db_url is not null -if (!MONGODB_URI) { - throw new Error( - "Please define the DATABASE_URL environment variable inside .env" - ); -} +// if (!MONGODB_URI) { +// throw new Error( +// "Please define the DATABASE_URL environment variable inside .env" +// ); +// } +// move inside the connectDB function so the test script can access type MongooseCache = { conn: typeof mongoose | null; @@ -28,6 +29,13 @@ const cached: MongooseCache = globalForMongoose.mongoose ?? { globalForMongoose.mongoose = cached; export async function connectToDatabase() { + const MONGODB_URI = process.env.DATABASE_URL!; // assert that db_url is not null + + if (!MONGODB_URI) { + throw new Error( + "Please define the DATABASE_URL environment variable inside .env" + ); + } if (cached.conn) { return cached.conn; } @@ -58,3 +66,8 @@ export async function disconnectDatabase() { } } } + +function test() { + console.log("not tested"); // making sure coverage can see which aren't tested + // coverage also can't test things that don't occur like errors +} diff --git a/models/Listing.ts b/models/Listing.ts index 2de73be..bf60c00 100644 --- a/models/Listing.ts +++ b/models/Listing.ts @@ -1,27 +1,65 @@ -import mongoose from "mongoose"; +import { + HydratedDocument, + InferSchemaType, + Model, + Schema, + model, + models, +} from "mongoose"; -const { Schema, model } = mongoose; -const MONGODB_URI = process.env.DATABASE_URL!; +const transformDocument = (_: unknown, ret: Record) => { + ret.id = ret._id?.toString(); + delete ret._id; + return ret; +}; // for properly handling toObject() or toJSON() and stringifying id -mongoose.connect(MONGODB_URI); - -const listingSchema = new Schema({ - _id: { type: String, required: true }, - itemId: { type: String, required: true }, - labId: { type: String, required: true }, - quantityAvailable: { type: Number, required: true }, - status: { type: String, enum: ["ACTIVE", "INACTIVE"], required: true }, - createdAt: { type: Date, required: true }, -}); - -listingSchema.index( +const listingSchema = new Schema( { - itemId: 1, - labId: 1, - createdAt: 1, + itemName: { type: String, required: true }, + itemId: { type: String, required: true }, + labName: { type: String }, + labLocation: { type: String }, + labId: { type: String, required: true }, + imageUrls: [{ type: String }], + quantityAvailable: { type: Number, required: true }, + createdAt: { type: Date, required: true }, + expiryDate: { type: Date }, + description: { type: String, default: "" }, + price: { type: Number, default: 0 }, + status: { type: String, enum: ["ACTIVE", "INACTIVE"], required: true }, + condition: { + type: String, + enum: ["New", "Good", "Fair", "Poor"], + required: true, + }, + hazardTags: [ + { + type: String, + enum: ["Physical", "Chemical", "Biological", "Other"], + }, + ], }, - { unique: true } + { + toJSON: { virtuals: true, versionKey: false, transform: transformDocument }, + toObject: { + virtuals: true, + versionKey: false, + transform: transformDocument, + }, + } ); -const listing = mongoose.models.Listing || model("Listing", listingSchema); -export default listing; +// for filtering +listingSchema.index({ labId: 1, createdAt: -1 }); +listingSchema.index({ itemId: 1, createdAt: -1 }); +listingSchema.index({ expiryDate: 1 }); +listingSchema.index({ hazardTags: 1 }); + +export type ListingInput = InferSchemaType; +export type Listing = ListingInput & { id: string }; +export type ListingDocument = HydratedDocument; + +const ListingModel: Model = + (models.Listing as Model) || + model("Listing", listingSchema); +export default ListingModel; diff --git a/package-lock.json b/package-lock.json index 737f487..5f42d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", "typescript": "^5" } }, @@ -645,6 +646,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -2362,6 +2387,34 @@ } } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3113,6 +3166,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4059,6 +4125,13 @@ "dev": true, "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4295,6 +4368,16 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -10332,6 +10415,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10586,6 +10720,13 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -10951,6 +11092,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index b1ed7a6..88b34fe 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", "typescript": "^5" } } diff --git a/playwright-report/.last-run.json b/playwright-report/.last-run.json new file mode 100644 index 0000000..cdc6ddf --- /dev/null +++ b/playwright-report/.last-run.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "failedTests": [ + "ae7629b28126d82233f9-d99f87c5846d54b5baeb" + ] +} \ No newline at end of file diff --git a/services/listings/__tests__/listings.test.ts b/services/listings/__tests__/listings.test.ts new file mode 100644 index 0000000..75202a4 --- /dev/null +++ b/services/listings/__tests__/listings.test.ts @@ -0,0 +1,342 @@ +import ListingModel, { ListingInput } from "@/models/Listing"; +import { + getListings, + getFilteredListings, + getListing, + addListing, + updateListing, + deleteListing, +} from "@/services/listings/listings"; + +// functions depend on model which we can't use to call db, so mock +jest.mock("@/models/Listing"); + +describe("Services: Successful Return Tests", () => { + // make sure tests are independent + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("sucessfully get all listings", async () => { + // arrange + const date = new Date(); + const id = "123"; + + const listingData = { + id: id, + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + const listingDoc = { + toObject: jest.fn().mockReturnValue(listingData), + }; + + (ListingModel.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue([listingDoc]), + }); + + // act + const result = await getListings(); + + // assert + expect(result.length).toBe(1); + expect(result).toEqual([listingData]); + }); + + test("successfully getFilteredListings", async () => { + // arrange + const date = new Date(); + const id = "123"; + + const listingData = { + id: id, + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + const listingDoc = { + toObject: jest.fn().mockReturnValue(listingData), + }; + + (ListingModel.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnThis(), // mock how the method chaining returns same query obj + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([listingDoc]), // mock the list of documents + }); + + (ListingModel.countDocuments as jest.Mock).mockResolvedValue(1); + + // act + const result = await getFilteredListings({ + page: 1, + limit: 10, + }); + + // assert + expect(result.listings.length).toBe(1); + expect(result).toEqual({ + listings: [listingData], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, + }); + }); + + test("successfully get a specific listing", async () => { + const date = new Date(); + const id = "123"; + + const listingData = { + id: id, + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + const listingDoc = { + toObject: jest.fn().mockReturnValue(listingData), + }; + + // id already exists, this mock only needs toObject method + (ListingModel.findById as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(listingDoc), // mock the mongoose doc + }); + + const result = await getListing(id); + + expect(result).toEqual(listingData); + expect(ListingModel.findById).toHaveBeenCalledWith(id); + }); + + test("successfully add a new listing", async () => { + const date = new Date(); + const id = "123"; + + const listingData: ListingInput = { + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + const listingDoc = { + toObject: jest.fn().mockReturnValue({ + id: id, + ...listingData, + }), + }; + (ListingModel.create as jest.Mock).mockResolvedValue(listingDoc); + + const result = await addListing(listingData); + + expect(result).toEqual({ + id: id, + ...listingData, + }); + expect(ListingModel.create).toHaveBeenCalledWith(listingData); + }); + + test("successfully update a listing", async () => { + const date = new Date(); + const id = "123"; + + const listingData: ListingInput = { + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + const listingDoc = { + toObject: jest.fn().mockReturnValue({ + id: id, + ...listingData, + }), + }; + (ListingModel.findByIdAndUpdate as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(listingDoc), + }); + + const result = await updateListing(id, listingData); + + expect(result).toEqual({ + id: id, + ...listingData, + }); + expect(ListingModel.findByIdAndUpdate).toHaveBeenCalledWith( + id, + listingData, + { + new: true, + runValidators: true, + } + ); + }); + + test("successfully delete a listing", async () => { + const id = "123"; + (ListingModel.findByIdAndDelete as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(true), + }); + + const result = await deleteListing(id); + + expect(result).toBe(true); + expect(ListingModel.findByIdAndDelete).toHaveBeenCalledWith(id); + }); +}); + +describe("Services: Null Return Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("listing DNE so getListing returns null", async () => { + const id = "123"; + + const listingDoc = null; + (ListingModel.findById as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(listingDoc), + }); + + const result = await getListing(id); + + expect(result).toBe(null); + expect(ListingModel.findById).toHaveBeenCalledWith(id); + }); + + test("listing DNE so updateListing returns null", async () => { + const date = new Date(); + const id = "123"; + + const listingData: ListingInput = { + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + const listingDoc = null; + (ListingModel.findByIdAndUpdate as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(listingDoc), + }); + + const result = await updateListing(id, listingData); + + expect(result).toBe(null); + expect(ListingModel.findByIdAndUpdate).toHaveBeenCalledWith( + id, + listingData, + { + new: true, + runValidators: true, + } + ); + }); +}); + +describe("Services: Error Return Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("db error in getListings", async () => { + (ListingModel.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error("DB Error")), + }); + + await expect(getListings()).rejects.toThrow("DB Error"); + }); + + test("db error in getFilteredListings", async () => { + (ListingModel.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockRejectedValue(new Error("DB Error")), + }); + + await expect(getFilteredListings({ page: 1, limit: 10 })).rejects.toThrow( + "DB Error" + ); + expect(ListingModel.find).toHaveBeenCalledWith({}); + }); + + test("db error in getListing", async () => { + const id = "123"; + (ListingModel.findById as jest.Mock).mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error("DB Error")), + }); + + await expect(getListing(id)).rejects.toThrow("DB Error"); + expect(ListingModel.findById).toHaveBeenCalledWith(id); + }); + + test("db error in addListing", async () => { + const date = new Date(); + const listingData: ListingInput = { + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + + (ListingModel.create as jest.Mock).mockRejectedValue(new Error("DB Error")); + + await expect(addListing(listingData)).rejects.toThrow("DB Error"); + expect(ListingModel.create).toHaveBeenCalledWith(listingData); + }); + + test("db error in updateListing", async () => { + const date = new Date(); + const id = "123"; + const listingData: ListingInput = { + itemId: "item1", + labId: "lab1", + quantityAvailable: 5, + status: "ACTIVE", + createdAt: date, + }; + (ListingModel.findByIdAndUpdate as jest.Mock).mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error("DB Error")), + }); + + await expect(updateListing(id, listingData)).rejects.toThrow("DB Error"); + expect(ListingModel.findByIdAndUpdate).toHaveBeenCalledWith( + id, + listingData, + { + new: true, + runValidators: true, + } + ); + }); + + test("db error in deleteListing", async () => { + const id = "123"; + (ListingModel.findByIdAndDelete as jest.Mock).mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error("DB Error")), + }); + + await expect(deleteListing(id)).rejects.toThrow("DB Error"); + expect(ListingModel.findByIdAndDelete).toHaveBeenCalledWith(id); + }); +}); diff --git a/services/listings/listings.ts b/services/listings/listings.ts new file mode 100644 index 0000000..20f6e7d --- /dev/null +++ b/services/listings/listings.ts @@ -0,0 +1,119 @@ +import type { HydratedDocument } from "mongoose"; +import ListingModel, { Listing, ListingInput } from "@/models/Listing"; + +type ListingDocument = HydratedDocument; +const toListing = (doc: ListingDocument): Listing => doc.toObject(); + +interface FilterParams { + labId?: string; + itemId?: string; + page: number; + limit: number; +} + +/** + * Get all listing entries (likely unused since filtered & paginated more realistic) + * @returns array of listings as JS objects + */ +async function getListings(): Promise { + const listings = await ListingModel.find().exec(); + return listings.map((listing) => toListing(listing)); +} + +/** + * Get filtered listing entries + * @returns array of listings as JS objects + */ +async function getFilteredListings({ + labId, + itemId, + page, + limit, +}: FilterParams) { + const query: any = {}; + if (labId) query.labId = labId; + if (itemId) query.itemId = itemId; + + const MAX_LIMIT = 20; // inquire about this in the future + const validPage = isNaN(page) || page < 1 ? 1 : page; + const validLimit = + isNaN(limit) || limit < 1 ? 10 : Math.min(limit, MAX_LIMIT); + const skip = (validPage - 1) * validLimit; + + const [listings, total] = await Promise.all([ + ListingModel.find(query) + .sort({ createdAt: -1 }) // Sort from newest to oldest + .skip(skip) + .limit(validLimit) + .exec(), // toListing handles doc to JS object, so lean unncessary + ListingModel.countDocuments(query), + ]); + + return { + listings: listings.map((listing) => toListing(listing)), + pagination: { + page: validPage, + limit: validLimit, + total, + totalPages: Math.ceil(total / validLimit), + }, + }; +} + +/** + * Get a listing entry by ID + * @param id the ID of the listing to get + * @returns the listing as a JS object + */ +async function getListing(id: string): Promise { + const listing = await ListingModel.findById(id).exec(); + return listing ? toListing(listing) : null; +} + +/** + * Add new listing entry + * @param newListing the listing data to add + * @returns the created listing as JS object + */ +async function addListing(newListing: ListingInput): Promise { + const createdListing = await ListingModel.create(newListing); + return toListing(createdListing); +} + +/** + * Update listing entry by ID + * @param id the ID of the listing to update + * @param data the data passed in to partially or fully update the listing + * @returns the updated listing or null if not found + */ +async function updateListing( + id: string, + data: Partial +): Promise { + const updatedListing = await ListingModel.findByIdAndUpdate(id, data, { + new: true, + runValidators: true, + }).exec(); + return updatedListing ? toListing(updatedListing) : null; +} + +/** + * Delete a listing entry by ID + * @param id the ID of the listing to delete + * @returns true if the listing was deleted, false otherwise + */ +// DON'T use this for tables that you don't actually need to potentially delete things from +// Could be used accidentally or misused maliciously to get rid of important data +async function deleteListing(id: string): Promise { + const deleted = await ListingModel.findByIdAndDelete(id).exec(); + return Boolean(deleted); +} + +export { + getListings, + getFilteredListings, + getListing, + addListing, + updateListing, + deleteListing, +}; diff --git a/tsconfig.json b/tsconfig.json index e7ff3a2..705f5ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] }