diff --git a/.gitignore b/.gitignore index 2b24613..2dfff53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ -.next/ +dist/ .env.local .DS_Store +.next +playwright-report/ .env \ No newline at end of file diff --git a/app/api/lab/[id]/__tests__/routes.test.ts b/app/api/lab/[id]/__tests__/routes.test.ts new file mode 100644 index 0000000..4aeedbb --- /dev/null +++ b/app/api/lab/[id]/__tests__/routes.test.ts @@ -0,0 +1,178 @@ +import { GET, PUT, DELETE } from "@/app/api/lab/[id]/route"; +import { + getLab, + updateLab, + deleteLab +} from "@/services/labs/labs"; + +jest.mock("@/services/labs/labs", () => ({ + getLab: jest.fn(), + updateLab: jest.fn(), + deleteLab: jest.fn(), +})); + +const mockGetLab = jest.mocked(getLab); +const mockUpdateLab = jest.mocked(updateLab); +const mockDeleteLab = jest.mocked(deleteLab); + +describe( "Lab API by ID", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe("GET /api/lab/[id]", () => { + it("should return a lab by ID", async () => { + // Mock the getLab function to return a sample lab + const mockLab = { id: "1", name: "Lab 1", department: "Cognitive Science", createdAt: new Date("2024-08-01T00:00:00Z") }; + mockGetLab.mockResolvedValue(mockLab); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab/1"), { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ ...mockLab, createdAt: mockLab.createdAt.toISOString() }); + }); + it("should return 404 if lab is not found", async () => { + // Mock the getLab function to return null + mockGetLab.mockResolvedValue(null); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab/999"), { params: { id: "999" } }); + // Assert that the response is correct + expect(response.status).toBe(404); + const data = await response.json(); + expect(data).toEqual({ message: "Lab not found" }); + }); + it("should return 400 for invalid ID", async () => { + // Create a mock request object with an invalid ID + const response = await GET(new Request("http://localhost/api/lab/"), { params: { id: "" } }); + // Assert that the response is correct + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ message: "Invalid ID" }); + }); + it("should return 500 on server error", async () => { + // Mock the getLab function to throw an error + mockGetLab.mockRejectedValue(new Error("Database error")); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab/1"), { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(500); + const data = await response.json(); + expect(data).toEqual({ message: "Internal server error" }); + }); + }); + describe ("PUT /api/lab/[id]", () => { + it("should update a lab by ID", async () => { + // Mock the updateLab function to return an updated lab + const mockUpdatedLab = { id: "1", name: "Updated Lab", department: "Cognitive Science", createdAt: new Date("2024-08-01T00:00:00Z") }; + mockUpdateLab.mockResolvedValue(mockUpdatedLab); + // Create a mock request object with valid data + const request = new Request("http://localhost/api/lab/1", { + method: "PUT", + body: JSON.stringify({ name: "Updated Lab", department: "Cognitive Science" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await PUT(request, { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ ...mockUpdatedLab, createdAt: mockUpdatedLab.createdAt.toISOString() }); + }); + it("should return 404 if lab to update is not found", async () => { + // Mock the updateLab function to return null + mockUpdateLab.mockResolvedValue(null); + // Create a mock request object with valid data + const request = new Request("http://localhost/api/lab/999", { + method: "PUT", + body: JSON.stringify({ name: "Updated Lab", department: "Cognitive Science" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await PUT(request, { params: { id: "999" } }); + // Assert that the response is correct + expect(response.status).toBe(404); + const data = await response.json(); + expect(data).toEqual({ message: "Lab not found" }); + }); + it("should return 400 for invalid ID", async () => { + // Create a mock request object with an invalid ID + const request = new Request("http://localhost/api/lab/", { + method: "PUT", + body: JSON.stringify({ name: "Updated Lab", department: "Cognitive Science" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await PUT(request, { params: { id: "" } }); + // Assert that the response is correct + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ message: "Invalid ID" }); + }); + it("should return 400 for invalid data", async () => { + // Create a mock request object with invalid data + const request = new Request("http://localhost/api/lab/1", { + method: "PUT", + body: JSON.stringify({ name: "", department: "" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await PUT(request, { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ message: "Invalid data" }); + }); + it("should return 500 on server error", async () => { + // Mock the updateLab function to throw an error + mockUpdateLab.mockRejectedValue(new Error("Database error")); + // Create a mock request object with valid data + const request = new Request("http://localhost/api/lab/1", { + method: "PUT", + body: JSON.stringify({ name: "Updated Lab", department: "Cognitive Science" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await PUT(request, { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(500); + const data = await response.json(); + expect(data).toEqual({ message: "Internal server error" }); + }); + }); + describe("DELETE /api/lab/[id]", () => { + it("should delete a lab by ID", async () => { + // Mock the deleteLab function to return true + mockDeleteLab.mockResolvedValue(true); + // Create a mock request object + const response = await DELETE(new Request("http://localhost/api/lab/1"), { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ message: "Lab deleted successfully" }); + }); + it("should return 404 if lab to delete is not found", async () => { + // Mock the deleteLab function to return false + mockDeleteLab.mockResolvedValue(false); + // Create a mock request object + const response = await DELETE(new Request("http://localhost/api/lab/999"), { params: { id: "999" } }); + // Assert that the response is correct + expect(response.status).toBe(404); + const data = await response.json(); + expect(data).toEqual({ message: "Lab not found" }); + }); + it("should return 400 for invalid ID", async () => { + // Create a mock request object with an invalid ID + const response = await DELETE(new Request("http://localhost/api/lab/"), { params: { id: "" } }); + // Assert that the response is correct + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ message: "Invalid ID" }); + }); + it("should return 500 on server error", async () => { + // Mock the deleteLab function to throw an error + mockDeleteLab.mockRejectedValue(new Error("Database error")); + // Create a mock request object + const response = await DELETE(new Request("http://localhost/api/lab/1"), { params: { id: "1" } }); + // Assert that the response is correct + expect(response.status).toBe(500); + const data = await response.json(); + expect(data).toEqual({ message: "Internal server error" }); + }); + }); + +}); \ No newline at end of file diff --git a/app/api/lab/[id]/route.ts b/app/api/lab/[id]/route.ts new file mode 100644 index 0000000..80c4a6e --- /dev/null +++ b/app/api/lab/[id]/route.ts @@ -0,0 +1,124 @@ +// /app/api/lab/[id]/route.ts + +/** + * API Route for Lab Management by ID + * This file defines the API routes for managing lab entries in the inventory + * system based on their unique ID. It includes handlers for fetching, + * updating, and deleting lab entries by ID. + */ + +'use server' + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getLab, updateLab, deleteLab } from "@/services/labs/labs"; + +type Params = { id: string }; + +// Define a Zod schema for validating lab updates, allowing for partial updates +const labUpdateSchema = z + .object({ + name: z.string().min(1), + department: z.string().min(1), + createdAt: z.coerce.date(), + }); + +/** + * Get one lab entry by ID + * @param request request object + * @param context context object containing route parameters + * @return response with lab data or error message + */ +export async function GET(request: Request, context : { params: Params }) { + try { + const parsedParams = z.object({ id: z.string().min(1) }) + .safeParse(context.params); + if (!parsedParams.success) { + return NextResponse.json({ message: "Invalid ID" }, + { status: 400 }); + } + const item = await getLab(parsedParams.data.id); + if (!item) { + return NextResponse.json({ message: "Lab not found" }, + { status: 404 }); + } + return NextResponse.json(item, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ message: "Internal server error" }, + { status: 500 }); + } +} + +/** + * Update a lab entry by ID + * @param request request object + * @param context context object containing route parameters + * @return response after updating lab entry + */ +export async function PUT(request: Request, context : { params: Params }) { + try { + // Validate the ID parameter and request body, then attempt to update + // the lab entry + const parsedParams = z.object({ id: z.string().min(1) }) + .safeParse(context.params); + if (!parsedParams.success) { + return NextResponse.json({ message: "Invalid ID" }, + { status: 400 }); + } + // Validate the request body against the lab update schema, allowing + // for partial updates + const parsedBody = labUpdateSchema.partial().safeParse( + await request.json()); + if (!parsedBody.success) { + return NextResponse.json({ message: "Invalid data" }, + { status: 400 }); + } + // Attempt to update the lab entry and return appropriate response + // based on the result + const updatedLab = await updateLab(parsedParams.data.id, + parsedBody.data); + if (!updatedLab) { + return NextResponse.json({ message: "Lab not found" }, + { status: 404 }); + } + // Return the updated lab entry if the update was successful + return NextResponse.json(updatedLab, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ message: "Internal server error" }, + { status: 500 }); + } +} + +/** + * Delete a lab entry by ID + * @param request request object + * @param context context object containing route parameters + * @return response after deleting the lab entry + */ +export async function DELETE(request: Request, context : { params: Params }) { + try { + // Validate the ID parameter and attempt to delete the lab entry + const parsedParams = z.object({ id: z.string().min(1) }).safeParse( + context.params); + if (!parsedParams.success) { + return NextResponse.json({ message: "Invalid ID" }, + { status: 400 }); + } + // Attempt to delete the lab entry and return appropriate response + // based on the result + const deleted = await deleteLab(parsedParams.data.id); + if (!deleted) { + return NextResponse.json({ message: "Lab not found" }, + { status: 404 }); + } + // Return a success message if the lab entry was deleted successfully + return NextResponse.json({ message: "Lab deleted successfully" }, + { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ message: "Internal server error" }, + { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/lab/__tests__/routes.test.ts b/app/api/lab/__tests__/routes.test.ts new file mode 100644 index 0000000..d722871 --- /dev/null +++ b/app/api/lab/__tests__/routes.test.ts @@ -0,0 +1,92 @@ +import { GET, POST } from "@/app/api/lab/route"; +import { + getLabs, + addLab +} from "@/services/labs/labs"; + + +jest.mock("@/services/labs/labs", () => ({ + getLabs: jest.fn(), + addLab: jest.fn(), +})); + +const mockGetLab = jest.mocked(getLabs); +const mockAddLab = jest.mocked(addLab); + +describe( "Lab API", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe("GET /api/lab", () => { + it("should return a list of labs", async () => { + // Mock the getLabs function to return a paginated result + const mockLabs = [ + { id: "1", name: "Lab 1", department: "Cognitive Science", createdAt: new Date("2024-08-01T00:00:00Z") }, + { id: "2", name: "Lab 2", department: "Biology", createdAt: new Date("2025-12-25T03:24:00Z") }, + ]; + const mockPayload = { data: mockLabs, pagination: { page: 1, limit: 10, total: 2, totalPages: 1 } }; + mockGetLab.mockResolvedValue(mockPayload); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab")); + // Assert that the response is correct + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ + data: mockLabs.map(lab => ({ ...lab, createdAt: lab.createdAt.toISOString() })), + pagination: mockPayload.pagination, + }); + }); + it("should return an empty array if no labs are found", async () => { + // Mock the getLabs function to return an empty paginated result + const mockPayload = { data: [], pagination: { page: 1, limit: 10, total: 0, totalPages: 0 } }; + mockGetLab.mockResolvedValue(mockPayload); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab")); + // Assert that the response is correct + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual(mockPayload); + }); + it("should return 500 on server error", async () => { + // Mock the getLabs function to throw an error + mockGetLab.mockRejectedValue(new Error("Database error")); + // Create a mock request object + const response = await GET(new Request("http://localhost/api/lab")); + // Assert that the response is correct + expect(response.status).toBe(500); + const data = await response.json(); + expect(data).toEqual({ message: "Internal server error" }); + }); + }); + describe("POST /api/lab", () => { + it("should create a new lab with valid data", async () => { + // Mock the addLab function to return a sample lab + const newLab = { id: "1", name: "New Lab", department: "Chemistry", createdAt: new Date() }; + mockAddLab.mockResolvedValue(newLab); + // Create a mock request object with valid data + const request = new Request("http://localhost/api/lab", { + method: "POST", + body: JSON.stringify({ name: "New Lab", department: "Chemistry", createdAt: new Date() }), + headers: { "Content-Type": "application/json" }, + }); + const response = await POST(request); + // Assert that the response is correct + expect(response.status).toBe(201); + const data = await response.json(); + expect(data).toEqual({ ...newLab, createdAt: newLab.createdAt.toISOString() }); + }); + it("should return 400 for invalid data", async () => { + // Create a mock request object with invalid data + const request = new Request("http://localhost/api/lab", { + method: "POST", + body: JSON.stringify({ name: "", department: "" }), + headers: { "Content-Type": "application/json" }, + }); + const response = await POST(request); + // Assert that the response is correct + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ message: "Invalid data" }); + }); + }); +}); diff --git a/app/api/lab/route.ts b/app/api/lab/route.ts new file mode 100644 index 0000000..ba408d1 --- /dev/null +++ b/app/api/lab/route.ts @@ -0,0 +1,59 @@ +// /app/api/lab/route.ts + +/** + * API Route for Lab Management + * This file defines the API routes for managing lab entries in the inventory + * system. It includes handlers for fetching all labs and creating new lab + * entries. + */ + +'use server' + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getLabs, addLab, GetLabOptions } from "@/services/labs/labs"; +import type { Lab } from "@/models/Lab"; + +const labCreateSchema = z.object({ + name: z.string().min(1), + department: z.string().min(1), + createdAt: z.coerce.date().optional(), +}); + +/** + * Fetch all lab entries + * @param request request object + * @return response with lab data + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + try { + const opts: GetLabOptions = { page, limit }; + const result = await getLabs(opts); + return NextResponse.json(result, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); + } +} + +/** + * Create a new lab entry + * @param request request object + * @return response after creating new lab entry + */ +export async function POST(request: Request) { + try { + const parsed = labCreateSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ message: "Invalid data" }, { status: 400 }); + } + const newLab = await addLab(parsed.data as unknown as Lab); + return NextResponse.json(newLab, { status: 201 }); + } catch (err) { + console.error(err); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/models/Lab.ts b/models/Lab.ts index a4f5ccf..b737274 100644 --- a/models/Lab.ts +++ b/models/Lab.ts @@ -1,15 +1,59 @@ -import mongoose from "mongoose"; +import { + HydratedDocument, + InferSchemaType, + Model, + Schema, + model, + models, + FlattenMaps, + Types +} from "mongoose"; -// Lab Schema definition -// Each lab has a unique name, department, and a timestamp for when it was created -const labSchema = new mongoose.Schema( +const transformDocument = (_: unknown, ret: Record) => { + ret.id = ret._id?.toString(); + delete ret._id; + return ret; +}; + +/** + * Lab Schema Definition + * This schema defines the structure of a lab entry in the inventory system. + * Each lab has a unique name, department, and a timestamp for when it was + * created. + */ +const labSchema = new Schema( { name: { type: String, required: true }, department: { type: String, required: true }, createdAt: { type: Date, required: true, default: Date.now } + }, + { + toJSON: { virtuals: true, versionKey: false, + transform: transformDocument }, + toObject: { virtuals: true, versionKey: false, + transform: transformDocument }, } ); -// Create and export the Lab model -const Lab = mongoose.models.Lab || mongoose.model("Lab", labSchema); -export default Lab; \ No newline at end of file +// Create and export the Lab model +export type LabInput = InferSchemaType; +export type Lab = Omit & { id: string }; +export type LabDocument = HydratedDocument; +// Lean type for Lab, used when fetching data without Mongoose document methods +export type LabLean = FlattenMaps & { _id: Types.ObjectId }; + +const LabModel: Model = + (models.Lab as Model) || model("Lab", labSchema); + +export default LabModel; + +// Utility functions to convert Mongoose documents to plain JavaScript objects +// with the desired structure +export const toLab = (doc: LabDocument): Lab => doc.toObject(); +export const toLabFromLean = (obj: LabLean): Lab => { + const { _id, ...rest } = obj as any; + return { + ...(rest as Omit), + id: String(_id), + }; +}; \ No newline at end of file diff --git a/services/labs/labs.ts b/services/labs/labs.ts new file mode 100644 index 0000000..417dd0d --- /dev/null +++ b/services/labs/labs.ts @@ -0,0 +1,83 @@ +import type { HydratedDocument }from "mongoose"; + +import { connectToDatabase } from "@/lib/mongoose"; +import LabModel, { Lab, LabInput, toLab, toLabFromLean } from "@/models/Lab"; + +export type GetLabOptions = { page?: number; limit?: number }; +export type LabPayload = Pick; + +/** + * Get all lab entries + * @returns an array of labs + */ +export async function getLabs(options?: GetLabOptions) { + await connectToDatabase(); + const page = Math.max(options?.page ?? 1, 1); + const limit = Math.max(options?.limit ?? 10, 1); + const skip = (page - 1) * limit; + + const [labs, total] = await Promise.all([ + LabModel.find().sort({ createdAt: -1 }).skip(skip).limit(limit).lean().exec(), + LabModel.countDocuments().exec(), + ]); + + return {data: labs.map(lab => toLabFromLean(lab)), pagination: {page, limit, total, totalPages: Math.ceil(total / limit)}}; +}; + + +/** + * Get a lab entry by ID + * @param id the ID of the lab to fetch + * @returns the lab + */ +export async function getLab(id: string): Promise { + await connectToDatabase(); + const lab = await LabModel.findById(id).lean().exec(); + return lab ? toLabFromLean(lab) : null; +} + +/** + * Add a new lab entry + * @param newLab the lab data to add + * @returns the created lab + */ +export async function addLab(newLab: Lab): Promise { + await connectToDatabase(); + const createdLab = await LabModel.create(newLab); + return toLab(createdLab); +} + +/** + * Update a lab entry by ID + * @param id the ID of the lab to update + * @param data the data to update + * @returns the updated lab or null if not found + */ +export async function updateLab( + id: string, + data: Partial, +): Promise { + await connectToDatabase(); + const updatedLab = await LabModel.findByIdAndUpdate(id, data, { + new: true, + runValidators: true, + }).exec(); + return updatedLab ? toLab(updatedLab) : null; +} + +/** + * Delete a lab entry by ID + * @param id the ID of the lab to delete + * @returns true if the lab 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 +export async function deleteLab(id: string): Promise { + await connectToDatabase(); + const lab = await LabModel.findById(id).exec(); + if (!lab) { + throw new Error(`Lab with ID ${id} not found`); + } + const deleted = await lab.deleteOne(); + return Boolean(deleted); +} \ No newline at end of file