diff --git a/app/api/UserLab/__tests__/route.test.ts b/app/api/UserLab/__tests__/route.test.ts new file mode 100644 index 0000000..c32f8cb --- /dev/null +++ b/app/api/UserLab/__tests__/route.test.ts @@ -0,0 +1,154 @@ +import { DELETE, GET, POST, PUT } from "@/app/api/UserLab/route"; +import { + addUserLab, + deleteUserLab, + getUserLab, + getUserLabs, + updateUserLab, +} from "@/services/UserLab"; + +jest.mock("@/services/UserLab", () => ({ + getUserLabs: jest.fn(), + getUserLab: jest.fn(), + addUserLab: jest.fn(), + updateUserLab: jest.fn(), + deleteUserLab: jest.fn(), +})); + +const mockedGetUserLabs = jest.mocked(getUserLabs); +const mockedGetUserLab = jest.mocked(getUserLab); +const mockedAddUserLab = jest.mocked(addUserLab); +const mockedUpdateUserLab = jest.mocked(updateUserLab); +const mockedDeleteUserLab = jest.mocked(deleteUserLab); + +describe("UserLab API route", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns a paginated list of user labs", async () => { + mockedGetUserLabs.mockResolvedValue({ + items: [{ _id: "1" }], + page: 2, + limit: 10, + total: 11, + totalPages: 2, + } as never); + + const response = await GET( + new Request("http://localhost/api/UserLab?page=2&limit=10") + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + items: [{ _id: "1" }], + page: 2, + limit: 10, + total: 11, + totalPages: 2, + }); + expect(mockedGetUserLabs).toHaveBeenCalledWith({ page: 2, limit: 10 }); + }); + + it("returns 400 when pagination exceeds the allowed limit", async () => { + const response = await GET( + new Request("http://localhost/api/UserLab?limit=20") + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + message: "Too big: expected number to be <=10", + }); + }); + + it("returns 404 when a requested entry does not exist", async () => { + mockedGetUserLab.mockResolvedValue(null as never); + + const response = await GET( + new Request("http://localhost/api/UserLab?id=507f1f77bcf86cd799439011") + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + message: "UserLab not found", + }); + }); + + it("defaults a new entry role to VIEWER", async () => { + mockedAddUserLab.mockResolvedValue({ _id: "1", role: "VIEWER" } as never); + + const response = await POST( + new Request("http://localhost/api/UserLab", { + method: "POST", + body: JSON.stringify({ + user: "507f1f77bcf86cd799439011", + lab: "507f1f77bcf86cd799439012", + }), + }) + ); + + expect(response.status).toBe(201); + expect(mockedAddUserLab).toHaveBeenCalledWith({ + user: "507f1f77bcf86cd799439011", + lab: "507f1f77bcf86cd799439012", + role: "VIEWER", + }); + }); + + it("returns 500 when POST hits an unexpected error", async () => { + mockedAddUserLab.mockRejectedValue(new Error("db down")); + + const response = await POST( + new Request("http://localhost/api/UserLab", { + method: "POST", + body: JSON.stringify({ + user: "507f1f77bcf86cd799439011", + lab: "507f1f77bcf86cd799439012", + role: "PI", + }), + }) + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + message: "Internal server error", + }); + }); + + it("returns 404 when PUT targets a missing entry", async () => { + mockedUpdateUserLab.mockResolvedValue(null as never); + + const response = await PUT( + new Request("http://localhost/api/UserLab", { + method: "PUT", + body: JSON.stringify({ + id: "507f1f77bcf86cd799439011", + update: { role: "LAB_MANAGER" }, + }), + }) + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + message: "UserLab not found", + }); + }); + + it("returns 200 after deleting an entry", async () => { + mockedDeleteUserLab.mockResolvedValue({ _id: "1" } as never); + + const response = await DELETE( + new Request("http://localhost/api/UserLab", { + method: "DELETE", + body: JSON.stringify({ + id: "507f1f77bcf86cd799439011", + }), + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + message: "UserLab deleted", + }); + }); +}); diff --git a/app/api/UserLab/route.ts b/app/api/UserLab/route.ts new file mode 100644 index 0000000..2541e6d --- /dev/null +++ b/app/api/UserLab/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { userLabRoleValues } from "@/models/UserLab"; +import { + addUserLab, + deleteUserLab, + getUserLab, + getUserLabs, + updateUserLab, +} from "@/services/UserLab"; + +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"); + +const paginationSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(10).default(10), +}); + +const userLabBaseSchema = z.object({ + user: objectIdSchema, + lab: objectIdSchema, + role: z.enum(userLabRoleValues).optional(), +}); + +const userLabUpdateSchema = userLabBaseSchema.partial(); + +function handleRouteError(error: unknown) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? "Invalid request" }, + { status: 400 } + ); + } + + console.error(error); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ); +} + +/** + * Get either a single UserLab entry by ID or a paginated list of entries. + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (id) { + const parsedId = objectIdSchema.safeParse(id); + if (!parsedId.success) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const entry = await getUserLab(parsedId.data); + if (!entry) { + return NextResponse.json({ message: "UserLab not found" }, { status: 404 }); + } + + return NextResponse.json(entry, { status: 200 }); + } + + const pagination = paginationSchema.parse({ + page: searchParams.get("page") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + }); + const entries = await getUserLabs(pagination); + return NextResponse.json(entries, { status: 200 }); + } catch (error) { + return handleRouteError(error); + } +} + +/** + * Create a new UserLab entry. + */ +export async function POST(request: Request) { + try { + const body = userLabBaseSchema.parse(await request.json()); + const created = await addUserLab({ + user: body.user, + lab: body.lab, + role: body.role ?? "VIEWER", + }); + + return NextResponse.json(created, { status: 201 }); + } catch (error) { + return handleRouteError(error); + } +} + +/** + * Update an existing UserLab entry by ID. + */ +export async function PUT(request: Request) { + try { + const validator = z.object({ + id: objectIdSchema, + update: userLabUpdateSchema, + }); + const parsed = validator.parse(await request.json()); + const updated = await updateUserLab(parsed.id, parsed.update); + + if (!updated) { + return NextResponse.json({ message: "UserLab not found" }, { status: 404 }); + } + + return NextResponse.json(updated, { status: 200 }); + } catch (error) { + return handleRouteError(error); + } +} + +/** + * Delete a UserLab entry by ID. + */ +export async function DELETE(request: Request) { + try { + const validator = z.object({ id: objectIdSchema }); + const { id } = validator.parse(await request.json()); + const deleted = await deleteUserLab(id); + + if (!deleted) { + return NextResponse.json({ message: "UserLab not found" }, { status: 404 }); + } + + return NextResponse.json({ message: "UserLab deleted" }, { status: 200 }); + } catch (error) { + return handleRouteError(error); + } +} diff --git a/models/UserLab.ts b/models/UserLab.ts new file mode 100644 index 0000000..7f80d36 --- /dev/null +++ b/models/UserLab.ts @@ -0,0 +1,53 @@ +import mongoose, { Document, Schema, Types } from "mongoose"; + +export const userLabRoleValues = [ + "PI", + "LAB_MANAGER", + "RESEARCHER", + "VIEWER", +] as const; + +export type UserLabRole = (typeof userLabRoleValues)[number]; + +export interface IUserLab extends Document { + user: Types.ObjectId; + lab: Types.ObjectId; + role: UserLabRole; + joinedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const userLabSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + lab: { + type: Schema.Types.ObjectId, + ref: "Lab", + required: true, + }, + role: { + type: String, + enum: userLabRoleValues, + default: "VIEWER", + }, + joinedAt: { + type: Date, + default: Date.now, + }, + }, + { + timestamps: true, + } +); + +userLabSchema.index({ user: 1, lab: 1 }, { unique: true }); + +const UserLab = + mongoose.models.UserLab || mongoose.model("UserLab", userLabSchema); + +export default UserLab; diff --git a/services/UserLab.ts b/services/UserLab.ts new file mode 100644 index 0000000..2f10d51 --- /dev/null +++ b/services/UserLab.ts @@ -0,0 +1,48 @@ +import { connectToDatabase } from "@/lib/mongoose"; +import { IUserLab } from "@/models/UserLab"; +import UserLab from "@/models/UserLab"; + +export type GetUserLabsOptions = { + page: number; + limit: number; +}; + +export type UserLabPayload = Pick; + +export async function getUserLabs({ page, limit }: GetUserLabsOptions) { + await connectToDatabase(); + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + UserLab.find().skip(skip).limit(limit).populate("user").populate("lab"), + UserLab.countDocuments(), + ]); + + return { + items, + page, + limit, + total, + totalPages: Math.ceil(total / limit) || 1, + }; +} + +export async function getUserLab(id: string) { + await connectToDatabase(); + return UserLab.findById(id).populate("user").populate("lab"); +} + +export async function addUserLab(data: UserLabPayload) { + await connectToDatabase(); + return UserLab.create(data); +} + +export async function updateUserLab(id: string, update: Partial) { + await connectToDatabase(); + return UserLab.findByIdAndUpdate(id, update, { new: true }); +} + +export async function deleteUserLab(id: string) { + await connectToDatabase(); + return UserLab.findByIdAndDelete(id); +} diff --git a/services/__tests__/UserLab.test.ts b/services/__tests__/UserLab.test.ts new file mode 100644 index 0000000..c8c984a --- /dev/null +++ b/services/__tests__/UserLab.test.ts @@ -0,0 +1,142 @@ +import { + addUserLab, + deleteUserLab, + getUserLab, + getUserLabs, + updateUserLab, +} from "@/services/UserLab"; +import { connectToDatabase } from "@/lib/mongoose"; +import UserLab from "@/models/UserLab"; + +jest.mock("@/lib/mongoose", () => ({ + connectToDatabase: jest.fn(), +})); + +jest.mock("@/models/UserLab", () => ({ + __esModule: true, + default: { + find: jest.fn(), + countDocuments: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }, +})); + +const mockedConnectToDatabase = jest.mocked(connectToDatabase); +const mockedUserLabModel = jest.mocked(UserLab); + +describe("UserLab service", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedConnectToDatabase.mockResolvedValue({} as never); + }); + + it("returns paginated user labs", async () => { + const populatedWithLab = Promise.resolve([{ _id: "1" }, { _id: "2" }]); + const populateLab = jest.fn().mockReturnValue(populatedWithLab); + const populateUser = jest.fn().mockReturnValue({ populate: populateLab }); + const limit = jest.fn().mockReturnValue({ populate: populateUser }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockedUserLabModel.find.mockReturnValue({ skip } as never); + mockedUserLabModel.countDocuments.mockResolvedValue(12 as never); + + const result = await getUserLabs({ page: 2, limit: 5 }); + + expect(connectToDatabase).toHaveBeenCalledTimes(1); + expect(mockedUserLabModel.find).toHaveBeenCalledTimes(1); + expect(skip).toHaveBeenCalledWith(5); + expect(limit).toHaveBeenCalledWith(5); + expect(populateUser).toHaveBeenCalledWith("user"); + expect(populateLab).toHaveBeenCalledWith("lab"); + expect(mockedUserLabModel.countDocuments).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + items: [{ _id: "1" }, { _id: "2" }], + page: 2, + limit: 5, + total: 12, + totalPages: 3, + }); + }); + + it("returns one page when total is zero", async () => { + const populatedWithLab = Promise.resolve([]); + const populateLab = jest.fn().mockReturnValue(populatedWithLab); + const populateUser = jest.fn().mockReturnValue({ populate: populateLab }); + const limit = jest.fn().mockReturnValue({ populate: populateUser }); + const skip = jest.fn().mockReturnValue({ limit }); + + mockedUserLabModel.find.mockReturnValue({ skip } as never); + mockedUserLabModel.countDocuments.mockResolvedValue(0 as never); + + const result = await getUserLabs({ page: 1, limit: 10 }); + + expect(result).toEqual({ + items: [], + page: 1, + limit: 10, + total: 0, + totalPages: 1, + }); + }); + + it("gets one user lab by id", async () => { + const populatedWithLab = Promise.resolve({ _id: "1" }); + const populateUser = jest.fn().mockReturnValue({ + populate: jest.fn().mockReturnValue(populatedWithLab), + }); + + mockedUserLabModel.findById.mockReturnValue({ populate: populateUser } as never); + + const result = await getUserLab("abc123"); + + expect(connectToDatabase).toHaveBeenCalledTimes(1); + expect(mockedUserLabModel.findById).toHaveBeenCalledWith("abc123"); + expect(populateUser).toHaveBeenCalledWith("user"); + expect(result).toEqual({ _id: "1" }); + }); + + it("creates a user lab entry", async () => { + const payload = { + user: "507f1f77bcf86cd799439011", + lab: "507f1f77bcf86cd799439012", + role: "PI", + } as never; + mockedUserLabModel.create.mockResolvedValue({ _id: "1", ...payload } as never); + + const result = await addUserLab(payload); + + expect(connectToDatabase).toHaveBeenCalledTimes(1); + expect(mockedUserLabModel.create).toHaveBeenCalledWith(payload); + expect(result).toEqual({ _id: "1", ...payload }); + }); + + it("updates a user lab entry", async () => { + mockedUserLabModel.findByIdAndUpdate.mockResolvedValue({ + _id: "1", + role: "VIEWER", + } as never); + + const result = await updateUserLab("abc123", { role: "VIEWER" } as never); + + expect(connectToDatabase).toHaveBeenCalledTimes(1); + expect(mockedUserLabModel.findByIdAndUpdate).toHaveBeenCalledWith( + "abc123", + { role: "VIEWER" }, + { new: true } + ); + expect(result).toEqual({ _id: "1", role: "VIEWER" }); + }); + + it("deletes a user lab entry", async () => { + mockedUserLabModel.findByIdAndDelete.mockResolvedValue({ _id: "1" } as never); + + const result = await deleteUserLab("abc123"); + + expect(connectToDatabase).toHaveBeenCalledTimes(1); + expect(mockedUserLabModel.findByIdAndDelete).toHaveBeenCalledWith("abc123"); + expect(result).toEqual({ _id: "1" }); + }); +});