diff --git a/course-matrix/backend/__tests__/integration-tests/timetableGenerate.test.ts b/course-matrix/backend/__tests__/integration-tests/timetableGenerate.test.ts new file mode 100644 index 00000000..a0df019e --- /dev/null +++ b/course-matrix/backend/__tests__/integration-tests/timetableGenerate.test.ts @@ -0,0 +1,407 @@ +import { + afterAll, + beforeEach, + describe, + expect, + it, + jest, + test, +} from "@jest/globals"; +import { Json } from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import { NextFunction, Request, Response } from "express"; +import request from "supertest"; + +import restrictionsController from "../../src/controllers/restrictionsController"; +import timetablesController from "../../src/controllers/timetablesController"; +import { supabase } from "../../src/db/setupDb"; +import app from "../../src/index"; +import { server } from "../../src/index"; +import { authHandler } from "../../src/middleware/authHandler"; +import getOfferings from "../../src/services/getOfferings"; + +const USER1 = "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01"; +const USER2 = "testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33"; +const USER3 = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; +const USER4 = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; +// USER5 is saved for courseOffering query do not use for anyother test +const USER5 = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + +// Handle AI import from index.ts +jest.mock("@ai-sdk/openai", () => ({ + createOpenAI: jest.fn(() => ({ + chat: jest.fn(), + })), +})); + +jest.mock("ai", () => ({ + streamText: jest.fn(() => + Promise.resolve({ pipeDataStreamToResponse: jest.fn() }), + ), +})); + +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: jest.fn(() => ({ + Index: jest.fn(() => ({ + query: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + })), + })), +})); + +jest.mock("node-cron", () => ({ + schedule: jest.fn(), // Mock the `schedule` function +})); + +afterAll(async () => { + server.close(); +}); + +// Function to create authenticated session dynamically based as provided +// user_id +const mockAuthHandler = (user_id: string) => { + return (req: Request, res: Response, next: NextFunction) => { + (req as any).user = { id: user_id }; // Inject user_id dynamically + next(); + }; +}; + +// Mock authHandler globally +jest.mock("../../src/middleware/authHandler", () => ({ + authHandler: jest.fn() as jest.MockedFunction, +})); + +// Mock timetables dataset +const mockTimetables1 = [ + { + id: 1, + name: "Timetable 1", + user_id: USER1, + }, + { + id: 2, + name: "Timetable 2", + user_id: USER1, + }, +]; + +// Mock list of offering +const offering1 = [ + { + id: 1, + course_id: 101, + meeting_section: "LEC01", + day: "MO", + start: "10:00:00", + end: "11:00:00", + }, + { + id: 2, + course_id: 101, + meeting_section: "LEC02", + day: "WE", + start: "10:00:00", + end: "11:00:00", + }, + { + id: 3, + course_id: 101, + meeting_section: "LEC03", + day: "FR", + start: "10:00:00", + end: "11:00:00", + }, +]; + +const offering2 = [ + { + id: 1, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "12:00:00", + }, +]; + +const offering3 = [ + { + id: 1, + course_id: 103, + day: "TU", + start: "15:00:00", + end: "17:00:00", + }, + { + id: 2, + course_id: 103, + day: "WE", + start: "15:00:00", + end: "17:00:00", + }, +]; + +// Spy on the getTimetables method +jest + .spyOn(timetablesController, "getTimetables") + .mockImplementation(timetablesController.getTimetables); + +// Spy on the createTimetable method +jest + .spyOn(timetablesController, "createTimetable") + .mockImplementation(timetablesController.createTimetable); + +// Spy on the updateTimetable method +jest + .spyOn(timetablesController, "updateTimetable") + .mockImplementation(timetablesController.updateTimetable); + +// Spy on the deleteTimetable method +jest + .spyOn(timetablesController, "deleteTimetable") + .mockImplementation(timetablesController.deleteTimetable); + +// Mock data set response to qeury +jest.mock("../../src/db/setupDb", () => ({ + supabase: { + // Mock return from schema, from and select to chain the next query + // command + schema: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + // Mock db response to .eq query command + eq: jest.fn().mockImplementation((key, value) => { + // Each test case is codded by the user_id in session + // DB response 1: Query user timetable return non null value + if (key === "user_id" && value === USER1) { + // Return mock data when user_id matches + return { data: mockTimetables1, error: null }; + } + // DB response 2: Query user timetable return null value + if (key === "user_id" && value === USER2) { + // Return null for this user_id + return { data: null, error: null }; + } + + // DB response 3: Combine .eq and .maybeSingle to signify that the + // return value could be single: Return non null value + if (key === "user_id" && value === USER3) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq + // if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: null, error: null }; + }), + }; + } + // DB response 4: Combine .eq and .maybeSingle to signify that the + // return value could be single: Return null value + if (key === "user_id" && value === USER4) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq + // if required + neq: jest.fn().mockImplementation(() => ({ + maybeSingle: jest + .fn() + .mockImplementation(() => ({ data: null, error: null })), + })), + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables1, error: null }; + }), + }; + } + // DB response with offering1 if courseID = 101 in request + if (key === "course_id" && value === 101) { + return { + eq: jest.fn().mockImplementation(() => { + return { data: offering1, error: null }; + }), + }; + } + // DB response with offering1 if courseID = 102 in request + if (key === "course_id" && value === 102) { + return { + eq: jest.fn().mockImplementation(() => { + return { data: offering2, error: null }; + }), + }; + } + // DB response with offering1 if courseID = 103 in request + if (key === "course_id" && value === 103) { + return { + eq: jest.fn().mockImplementation(() => { + return { data: offering1, error: null }; + }), + }; + } + }), + // Mock db response to .insert query command + insert: jest.fn().mockImplementation((data: Json) => { + // DB response 5: Create timetable successfully, new timetable data is + // responded + if (data && data[0].user_id === USER3) { + return { + select: jest.fn().mockImplementation(() => { + // Return the input data when select is called + return { + data: data, + error: null, + }; // Return the data passed to insert + }), + }; + } + // DB response 6: Create timetable uncessfully, return error.message + return { + select: jest.fn().mockImplementation(() => { + return { data: null, error: { message: "Fail to create timetable" } }; + }), + }; + }), + + // Mock db response to .update query command + update: jest.fn().mockImplementation((updatedata: Json) => { + // DB response 7: Timetable updated successfully, db return updated + // data in response + if (updatedata && updatedata.timetable_title === "Updated Title") { + return { + eq: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + single: jest.fn().mockImplementation((data) => { + return { data: updatedata, error: null }; + }), + }; + } + // DB response 8: Update timetable uncessfully, return error.message + return { data: null, error: { message: "Fail to update timetable" } }; + }), + + // Mock db response to .delete query command + delete: jest.fn().mockImplementation(() => { + // DB response 9: Delete timetable successfully + return { + eq: jest.fn().mockReturnThis(), + data: null, + error: null, + }; + }), + }, +})); + +// Test block +describe("Simple test case for offering", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return offering1", async () => { + const response = await getOfferings(101, "Spring"); + expect(response).toEqual(offering1); + }); +}); + +// Test block +describe("Testing timetable generation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Generate for 1 course", async () => { + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(USER5)); + const response = await request(app) + .post("/api/timetables/generate") + .send({ courses: [{ id: 101 }], semester: "Spring", restrictions: [] }) + .expect(200); + expect(JSON.parse(response.text).amount).toEqual(3); // 3 timetables should be generated. + }); + + test("Generate for 2 courses, no conflict", async () => { + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(USER5)); + const response = await request(app) + .post("/api/timetables/generate") + .send({ + courses: [{ id: 101 }, { id: 103 }], + semester: "Spring", + restrictions: [], + }) + .expect(200); + expect(JSON.parse(response.text).amount).toEqual(6); // 6 timetables should be generated. + }); + + test("Generate for 2 courses, conflict", async () => { + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(USER5)); + const response = await request(app) + .post("/api/timetables/generate") + .send({ + courses: [{ id: 101 }, { id: 102 }], + semester: "Spring", + restrictions: [], + }) + .expect(200); + expect(JSON.parse(response.text).amount).toEqual(2); + }); + + test("Generate for 1 course, bad restriction", async () => { + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(USER5)); + const response = await request(app) + .post("/api/timetables/generate") + .send({ + courses: [{ id: 101 }], + semester: "Spring", + restrictions: [ + { + type: "Restrict Before", + days: ["MO", "TU", "WE", "TH", "FR"], + endTime: "21:00:00", + disabled: false, + }, + ], + }) + .expect(404); + }); + + test("Generate for 1 course w/ restrictions, only wednesday should be allowed", async () => { + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(USER5)); + const response = await request(app) + .post("/api/timetables/generate") + .send({ + courses: [{ id: 101 }], + semester: "Spring", + restrictions: [ + { + type: "Restrict Before", + days: ["MO", "TU", "TH", "FR"], + endTime: "21:00:00", + disabled: false, + }, + ], + }) + .expect(200); + expect(JSON.parse(response.text).amount).toEqual(1); + expect(JSON.parse(response.text).schedules).toEqual([ + [ + { + id: 2, + course_id: 101, + meeting_section: "LEC02", + day: "WE", + start: "10:00:00", + end: "11:00:00", + }, + ], + ]); + }); +}); diff --git a/course-matrix/backend/__tests__/unit-tests/auth.test.ts b/course-matrix/backend/__tests__/unit-tests/auth.test.ts index 7a5cf638..f3c4e049 100644 --- a/course-matrix/backend/__tests__/unit-tests/auth.test.ts +++ b/course-matrix/backend/__tests__/unit-tests/auth.test.ts @@ -1,6 +1,7 @@ -import request from "supertest"; +import { afterAll, describe, expect, it, jest } from "@jest/globals"; import { Request, Response } from "express"; -import { jest, describe, it, expect, afterAll } from "@jest/globals"; +import request from "supertest"; + import app, { server } from "../../src/index"; jest.mock("@ai-sdk/openai", () => ({ @@ -167,16 +168,18 @@ describe("Authentication API", () => { describe("POST /auth/login", () => { it("should return 200 and a token for valid credentials", async () => { - const response = await request(app) - .post("/auth/login") - .send({ email: "validUser", password: "validPassword" }); + const response = await request(app).post("/auth/login").send({ + email: "validUser", + password: "validPassword", + }); expect(response.status).toBe(200); expect(response.body).toHaveProperty("token"); }); it("should return 401 for invalid credentials", async () => { - const response = await request(app) - .post("/auth/login") - .send({ email: "invalidUser", password: "wrongPassword" }); + const response = await request(app).post("/auth/login").send({ + email: "invalidUser", + password: "wrongPassword", + }); expect(response.status).toBe(401); expect(response.body).toHaveProperty("error", "Invalid credentials"); }); @@ -237,7 +240,9 @@ describe("Authentication API", () => { it("should return 200 and a success message when email is provided", async () => { const response = await request(app) .post("/auth/request-password-reset") - .send({ email: "user@example.com" }); + .send({ + email: "user@example.com", + }); expect(response.status).toBe(200); expect(response.body).toHaveProperty( "message", @@ -256,9 +261,10 @@ describe("Authentication API", () => { describe("POST /auth/reset-password", () => { it("should return 200 and a success message when password and token are provided", async () => { - const response = await request(app) - .post("/auth/reset-password") - .send({ password: "newPassword123", token: "validToken" }); + const response = await request(app).post("/auth/reset-password").send({ + password: "newPassword123", + token: "validToken", + }); expect(response.status).toBe(200); expect(response.body).toHaveProperty( "message", @@ -267,9 +273,9 @@ describe("Authentication API", () => { }); it("should return 400 if password or token is missing", async () => { - const response = await request(app) - .post("/auth/reset-password") - .send({ password: "newPassword123" }); + const response = await request(app).post("/auth/reset-password").send({ + password: "newPassword123", + }); expect(response.status).toBe(400); expect(response.body).toHaveProperty( "error", @@ -280,9 +286,9 @@ describe("Authentication API", () => { describe("DELETE /auth/accountDelete", () => { it("should return 200 and a success message when userId is provided", async () => { - const response = await request(app) - .delete("/auth/accountDelete") - .send({ userId: "12345" }); + const response = await request(app).delete("/auth/accountDelete").send({ + userId: "12345", + }); expect(response.status).toBe(200); expect(response.body).toHaveProperty( "message", diff --git a/course-matrix/backend/__tests__/unit-tests/getOfferings.test.ts b/course-matrix/backend/__tests__/unit-tests/getOfferings.test.ts new file mode 100644 index 00000000..226011ee --- /dev/null +++ b/course-matrix/backend/__tests__/unit-tests/getOfferings.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, jest, test } from "@jest/globals"; + +import { supabase } from "../../src/db/setupDb"; +import getOfferings from "../../src/services/getOfferings"; + +jest.mock("../../src/db/setupDb", () => ({ + supabase: { + schema: jest.fn(), + }, +})); + +type SupabaseQueryResult = Promise<{ data: any; error: any }>; + +describe("getOfferings", () => { + it("returns offering data for a valid course and semester", async () => { + const mockData = [ + { + id: 1, + course_id: 123, + meeting_section: "L01", + offering: "Fall 2025", + day: "Mon", + start: "10:00", + end: "11:00", + location: "Room 101", + current: 30, + max: 40, + is_waitlisted: false, + delivery_mode: "In-Person", + instructor: "Dr. Smith", + notes: "", + code: "ABC123", + }, + ]; + + // Build the method chain mock + const eqMock2 = jest.fn<() => SupabaseQueryResult>().mockResolvedValue({ + data: mockData, + error: null, + }); + const eqMock1 = jest.fn(() => ({ eq: eqMock2 })); + const selectMock = jest.fn(() => ({ eq: eqMock1 })); + const fromMock = jest.fn(() => ({ select: selectMock })); + const schemaMock = jest.fn(() => ({ from: fromMock })); + + // Replace supabase.schema with our chain + (supabase.schema as jest.Mock).mockImplementation(schemaMock); + + // Act + const result = await getOfferings(123, "Fall 2025"); + + // Assert + expect(schemaMock).toHaveBeenCalledWith("course"); + expect(fromMock).toHaveBeenCalledWith("offerings"); + expect(selectMock).toHaveBeenCalled(); + expect(eqMock1).toHaveBeenCalledWith("course_id", 123); + expect(eqMock2).toHaveBeenCalledWith("offering", "Fall 2025"); + expect(result).toEqual(mockData); + }); +}); diff --git a/course-matrix/backend/__tests__/unit-tests/restrictionsController.test.ts b/course-matrix/backend/__tests__/unit-tests/restrictionsController.test.ts index d637eb43..c275ef49 100644 --- a/course-matrix/backend/__tests__/unit-tests/restrictionsController.test.ts +++ b/course-matrix/backend/__tests__/unit-tests/restrictionsController.test.ts @@ -1,25 +1,26 @@ -import request from "supertest"; -import restrictionsController from "../../src/controllers/restrictionsController"; -import { Request, Response, NextFunction } from "express"; import { - jest, + afterAll, + beforeEach, describe, - test, expect, - beforeEach, - afterAll, + jest, + test, } from "@jest/globals"; -import { authHandler } from "../../src/middleware/authHandler"; -import { supabase } from "../../src/db/setupDb"; import { instanceOfErrorResponse, Json, } from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import { NextFunction, Request, Response } from "express"; +import request from "supertest"; + +import restrictionsController from "../../src/controllers/restrictionsController"; +import { supabase } from "../../src/db/setupDb"; import app from "../../src/index"; import { server } from "../../src/index"; +import { authHandler } from "../../src/middleware/authHandler"; import { errorConverter } from "../../src/middleware/errorHandler"; -//Handle AI import from index.ts +// Handle AI import from index.ts jest.mock("@ai-sdk/openai", () => ({ createOpenAI: jest.fn(() => ({ chat: jest.fn(), @@ -50,7 +51,8 @@ afterAll(async () => { server.close(); }); -// Function to create authenticated session dynamically based as provided user_id +// Function to create authenticated session dynamically based as provided +// user_id const mockAuthHandler = (user_id: string) => { return (req: Request, res: Response, next: NextFunction) => { (req as any).user = { id: user_id }; // Inject user_id dynamically @@ -63,11 +65,14 @@ jest.mock("../../src/middleware/authHandler", () => ({ authHandler: jest.fn() as jest.MockedFunction, })); +const USER1 = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; +const USER2 = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + // Mock timetables dataset const mockRestriction = { id: 1, restriction_type: "Restrict Between", - user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", + user_id: USER1, start_time: "13:30:00", end_time: "14:30:00", days: ["MO", "TUE"], @@ -78,7 +83,7 @@ const mockRestriction2 = { id: 1, calendar_id: 1, restriction_type: "Restrict Between", - user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", + user_id: USER2, start_time: "13:30:00", end_time: "14:30:00", days: ["MO", "TUE"], @@ -87,12 +92,12 @@ const mockRestriction2 = { const mockTimetables1 = { id: 1, - user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", + user_id: USER1, }; const mockTimetables2 = { id: 1, - user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", + user_id: USER2, }; // Spy on the createRestriction method @@ -118,15 +123,17 @@ jest // Mock data set response to qeury jest.mock("../../src/db/setupDb", () => ({ supabase: { - //Mock return from schema, from and select to chain the next query command + // Mock return from schema, from and select to chain the next query + // command schema: jest.fn().mockReturnThis(), from: jest.fn().mockImplementation((key, value) => { if (key === "timetables") { return { select: jest.fn().mockReturnThis(), eq: jest.fn().mockImplementation((key, value) => { - //Each test case is codded by the user_id in session - //DB response 3: Combine .eq and .maybeSingle to signify that the return value could be single: Return non null value + // Each test case is codded by the user_id in session + // DB response 3: Combine .eq and .maybeSingle to signify that + // the return value could be single: Return non null value if ( key === "user_id" && value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" @@ -138,25 +145,22 @@ jest.mock("../../src/db/setupDb", () => ({ }), }; } - //DB response 4: Combine .eq and .maybeSingle to signify that the return value could be single: Return null value - if ( - key === "user_id" && - value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + // DB response 4: Combine .eq and .maybeSingle to signify that + // the return value could be single: Return null value + if (key === "user_id" && value === USER1) { return { - eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + eq: jest.fn().mockReturnThis(), // Allow further chaining + // of eq if required maybeSingle: jest.fn().mockImplementation(() => { return { data: mockTimetables1, error: null }; }), }; } - if ( - key === "user_id" && - value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + if (key === "user_id" && value === USER2) { return { - eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + eq: jest.fn().mockReturnThis(), // Allow further chaining + // of eq if required maybeSingle: jest.fn().mockImplementation(() => { return { data: mockTimetables2, error: null }; }), @@ -188,10 +192,7 @@ jest.mock("../../src/db/setupDb", () => ({ }), }; } - if ( - key === "user_id" && - value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + if (key === "user_id" && value === USER1) { return { eq: jest.fn().mockImplementation((key, value) => { if (key === "calendar_id" && value === "1") { @@ -200,10 +201,7 @@ jest.mock("../../src/db/setupDb", () => ({ }), }; } - if ( - key === "user_id" && - value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + if (key === "user_id" && value === USER2) { return { eq: jest.fn().mockImplementation((key, value) => { if (key === "calendar_id" && value === "1") { @@ -220,20 +218,21 @@ jest.mock("../../src/db/setupDb", () => ({ return { data: null, error: null }; }), insert: jest.fn().mockImplementation((data: Json) => { - //DB response 5: Create timetable successfully, new timetable data is responded - if ( - data && - data[0].user_id === - "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + // DB response 5: Create timetable successfully, new timetable + // data is responded + if (data && data[0].user_id === USER1) { return { select: jest.fn().mockImplementation(() => { // Return the input data when select is called - return { data: data, error: null }; // Return the data passed to insert + return { + data: data, + error: null, + }; // Return the data passed to insert }), }; } - //DB response 6: Create timetable uncessfully, return error.message + // DB response 6: Create timetable uncessfully, return + // error.message return { select: jest.fn().mockImplementation(() => { return { @@ -244,7 +243,8 @@ jest.mock("../../src/db/setupDb", () => ({ }; }), update: jest.fn().mockImplementation((updatedata: Json) => { - //DB response 7: Timetable updated successfully, db return updated data in response + // DB response 7: Timetable updated successfully, db return + // updated data in response if (updatedata && updatedata.start_time === "09:00:00.000Z") { return { eq: jest.fn().mockReturnThis(), @@ -253,17 +253,15 @@ jest.mock("../../src/db/setupDb", () => ({ }), }; } - //DB response 8: Update timetable uncessfully, return error.message + // DB response 8: Update timetable uncessfully, return + // error.message return { data: null, error: { message: "Fail to update timetable" } }; }), delete: jest.fn().mockImplementation(() => { - //DB response 9: Delete timetable successfully + // DB response 9: Delete timetable successfully return { eq: jest.fn().mockImplementation((key, value) => { - if ( - key === "user_id" && - value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + if (key === "user_id" && value === USER2) { return { eq: jest.fn().mockReturnThis(), data: null, @@ -292,7 +290,7 @@ jest.mock("../../src/db/setupDb", () => ({ }, })); -//Test block 1: Get endpoint +// Test block 1: Get endpoint describe("GET /api/timetables/restrictions/:id", () => { beforeEach(() => { jest.clearAllMocks(); @@ -302,9 +300,7 @@ describe("GET /api/timetables/restrictions/:id", () => { // Initialize the authenticated session ( authHandler as jest.MockedFunction - ).mockImplementationOnce( - mockAuthHandler("testuser04-f84fd0da-d775-4424-ad88-d9675282453c"), - ); + ).mockImplementationOnce(mockAuthHandler(USER1)); const response = await request(app).get("/api/timetables/restrictions/1"); @@ -330,7 +326,7 @@ describe("GET /api/timetables/restrictions/:id", () => { }); }); -//Test block 2: POST endpoint +// Test block 2: POST endpoint describe("POST /api/timetables/restrictions", () => { beforeEach(() => { jest.clearAllMocks(); @@ -377,7 +373,7 @@ describe("POST /api/timetables/restrictions", () => { }); test("should create a new timetable given calendar_id, start_time and end_time", async () => { - const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER1; const newRestriction = { calendar_id: "1", start_time: "2025-03-04T09:00:00.000Z", @@ -428,14 +424,14 @@ describe("POST /api/timetables/restrictions", () => { }); }); -//Test block 3: Put endpoint +// Test block 3: Put endpoint describe("PUT /api/timetables/restrictions/:id", () => { beforeEach(() => { jest.clearAllMocks(); }); test("should return error code 400 and message: calendar id is required ", async () => { // Make sure the test user is authenticated - const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER2; const timetableData = { start_time: "2025-03-04T09:00:00.000Z", }; @@ -458,7 +454,7 @@ describe("PUT /api/timetables/restrictions/:id", () => { test("should update the timetable successfully", async () => { // Make sure the test user is authenticated - const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER2; const timetableData = { start_time: "2025-03-04T09:00:00.000Z", }; @@ -503,7 +499,7 @@ describe("PUT /api/timetables/restrictions/:id", () => { }); }); -//Test block 4: Delete endpoint +// Test block 4: Delete endpoint describe("DELETE /api/timetables/:id", () => { beforeEach(() => { jest.clearAllMocks(); @@ -511,7 +507,7 @@ describe("DELETE /api/timetables/:id", () => { test("should delete the timetable successfully", async () => { // Make sure the test user is authenticated - const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER2; // Mock authHandler to simulate the user being logged in ( diff --git a/course-matrix/backend/__tests__/unit-tests/timetablesController.test.ts b/course-matrix/backend/__tests__/unit-tests/timetablesController.test.ts index 50d42beb..7121a6ba 100644 --- a/course-matrix/backend/__tests__/unit-tests/timetablesController.test.ts +++ b/course-matrix/backend/__tests__/unit-tests/timetablesController.test.ts @@ -1,21 +1,27 @@ -import request from "supertest"; -import timetablesController from "../../src/controllers/timetablesController"; -import { Request, Response, NextFunction } from "express"; import { - jest, + afterAll, + beforeEach, describe, - test, expect, - beforeEach, - afterAll, + jest, + test, } from "@jest/globals"; -import { authHandler } from "../../src/middleware/authHandler"; -import { supabase } from "../../src/db/setupDb"; import { Json } from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import { NextFunction, Request, Response } from "express"; +import request from "supertest"; + +import timetablesController from "../../src/controllers/timetablesController"; +import { supabase } from "../../src/db/setupDb"; import app from "../../src/index"; import { server } from "../../src/index"; +import { authHandler } from "../../src/middleware/authHandler"; + +const USER1 = "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01"; +const USER2 = "testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33"; +const USER3 = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; +const USER4 = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; -//Handle AI import from index.ts +// Handle AI import from index.ts jest.mock("@ai-sdk/openai", () => ({ createOpenAI: jest.fn(() => ({ chat: jest.fn(), @@ -46,7 +52,8 @@ afterAll(async () => { server.close(); }); -// Function to create authenticated session dynamically based as provided user_id +// Function to create authenticated session dynamically based as provided +// user_id const mockAuthHandler = (user_id: string) => { return (req: Request, res: Response, next: NextFunction) => { (req as any).user = { id: user_id }; // Inject user_id dynamically @@ -64,12 +71,12 @@ const mockTimetables1 = [ { id: 1, name: "Timetable 1", - user_id: "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01", + user_id: USER1, }, { id: 2, name: "Timetable 2", - user_id: "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01", + user_id: USER1, }, ]; @@ -96,49 +103,42 @@ jest // Mock data set response to qeury jest.mock("../../src/db/setupDb", () => ({ supabase: { - //Mock return from schema, from and select to chain the next query command + // Mock return from schema, from and select to chain the next query + // command schema: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), // Mock db response to .eq query command eq: jest.fn().mockImplementation((key, value) => { - //Each test case is codded by the user_id in session - //DB response 1: Query user timetable return non null value - if ( - key === "user_id" && - value === "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01" - ) { + // Each test case is codded by the user_id in session + // DB response 1: Query user timetable return non null value + if (key === "user_id" && value === USER1) { // Return mock data when user_id matches return { data: mockTimetables1, error: null }; } - //DB response 2: Query user timetable return null value - if ( - key === "user_id" && - value === "testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33" - ) { + // DB response 2: Query user timetable return null value + if (key === "user_id" && value === USER2) { // Return null for this user_id return { data: null, error: null }; } - //DB response 3: Combine .eq and .maybeSingle to signify that the return value could be single: Return non null value - if ( - key === "user_id" && - value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + // DB response 3: Combine .eq and .maybeSingle to signify that the + // return value could be single: Return non null value + if (key === "user_id" && value === USER3) { return { - eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq + // if required maybeSingle: jest.fn().mockImplementation(() => { return { data: null, error: null }; }), }; } - //DB response 4: Combine .eq and .maybeSingle to signify that the return value could be single: Return null value - if ( - key === "user_id" && - value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + // DB response 4: Combine .eq and .maybeSingle to signify that the + // return value could be single: Return null value + if (key === "user_id" && value === USER4) { return { - eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq + // if required neq: jest.fn().mockImplementation(() => ({ maybeSingle: jest .fn() @@ -152,19 +152,20 @@ jest.mock("../../src/db/setupDb", () => ({ }), // Mock db response to .insert query command insert: jest.fn().mockImplementation((data: Json) => { - //DB response 5: Create timetable successfully, new timetable data is responded - if ( - data && - data[0].user_id === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" - ) { + // DB response 5: Create timetable successfully, new timetable data is + // responded + if (data && data[0].user_id === USER3) { return { select: jest.fn().mockImplementation(() => { // Return the input data when select is called - return { data: data, error: null }; // Return the data passed to insert + return { + data: data, + error: null, + }; // Return the data passed to insert }), }; } - //DB response 6: Create timetable uncessfully, return error.message + // DB response 6: Create timetable uncessfully, return error.message return { select: jest.fn().mockImplementation(() => { return { data: null, error: { message: "Fail to create timetable" } }; @@ -174,7 +175,8 @@ jest.mock("../../src/db/setupDb", () => ({ // Mock db response to .update query command update: jest.fn().mockImplementation((updatedata: Json) => { - //DB response 7: Timetable updated successfully, db return updated data in response + // DB response 7: Timetable updated successfully, db return updated + // data in response if (updatedata && updatedata.timetable_title === "Updated Title") { return { eq: jest.fn().mockReturnThis(), @@ -184,13 +186,13 @@ jest.mock("../../src/db/setupDb", () => ({ }), }; } - //DB response 8: Update timetable uncessfully, return error.message + // DB response 8: Update timetable uncessfully, return error.message return { data: null, error: { message: "Fail to update timetable" } }; }), // Mock db response to .delete query command delete: jest.fn().mockImplementation(() => { - //DB response 9: Delete timetable successfully + // DB response 9: Delete timetable successfully return { eq: jest.fn().mockReturnThis(), data: null, @@ -200,7 +202,7 @@ jest.mock("../../src/db/setupDb", () => ({ }, })); -//Test block 1: Get endpoint +// Test block 1: Get endpoint describe("GET /api/timetables", () => { beforeEach(() => { jest.clearAllMocks(); @@ -210,9 +212,7 @@ describe("GET /api/timetables", () => { // Initialize the authenticated session ( authHandler as jest.MockedFunction - ).mockImplementationOnce( - mockAuthHandler("testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01"), - ); + ).mockImplementationOnce(mockAuthHandler(USER1)); const response = await request(app).get("/api/timetables"); @@ -225,9 +225,7 @@ describe("GET /api/timetables", () => { // Initialize the authenticated session ( authHandler as jest.MockedFunction - ).mockImplementationOnce( - mockAuthHandler("testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33"), - ); + ).mockImplementationOnce(mockAuthHandler(USER2)); const response = await request(app).get("/api/timetables"); // Verify the response status and error message @@ -238,14 +236,14 @@ describe("GET /api/timetables", () => { }); }); -//Test block 2: POST endpoint +// Test block 2: POST endpoint describe("POST /api/timetables", () => { beforeEach(() => { jest.clearAllMocks(); }); test("should return error code 400 and error message: 'timetable title and semester are required' when request body miss timetable_title or semester", async () => { - const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER3; const newTimetable = {}; ( @@ -264,7 +262,7 @@ describe("POST /api/timetables", () => { }); test("should create a new timetable given timetable title, timetable semester, timetable favorite", async () => { - const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER3; const newTimetable = { timetable_title: "Minh timetable", semester: "Fall 2025", @@ -292,7 +290,7 @@ describe("POST /api/timetables", () => { }); test("should return error code 400 and error message: A timetable with this title already exists when checkduplicate query return a value", async () => { - const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER4; const newTimetable = { timetable_title: "Minh timetable", semester: "Fall 2025", @@ -315,7 +313,7 @@ describe("POST /api/timetables", () => { }); }); -//Test block 3: Put endpoint +// Test block 3: Put endpoint describe("PUT /api/timetables/:id", () => { beforeEach(() => { jest.clearAllMocks(); @@ -323,7 +321,7 @@ describe("PUT /api/timetables/:id", () => { test("should update the timetable successfully", async () => { // Make sure the test user is authenticated - const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER4; const timetableData = { timetable_title: "Updated Title", semester: "Spring 2025", @@ -348,7 +346,7 @@ describe("PUT /api/timetables/:id", () => { test("should return error code 404 and message: Calendar id not found and id not found", async () => { // Make sure the test user is authenticated - const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER3; const timetableData = { timetable_title: "Updated Title", semester: "Spring 2025", @@ -371,7 +369,7 @@ describe("PUT /api/timetables/:id", () => { }); }); -//Test block 4: Delete endpoint +// Test block 4: Delete endpoint describe("DELETE /api/timetables/:id", () => { beforeEach(() => { jest.clearAllMocks(); @@ -379,7 +377,7 @@ describe("DELETE /api/timetables/:id", () => { test("should delete the timetable successfully", async () => { // Make sure the test user is authenticated - const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER4; // Mock authHandler to simulate the user being logged in ( @@ -397,7 +395,7 @@ describe("DELETE /api/timetables/:id", () => { test("should return error code 404 and message: Calendar id not found and id not found", async () => { // Make sure the test user is authenticated - const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const user_id = USER3; // Mock authHandler to simulate the user being logged in ( diff --git a/course-matrix/backend/src/constants/constants.ts b/course-matrix/backend/src/constants/constants.ts index d9535ab1..cddfcfc4 100644 --- a/course-matrix/backend/src/constants/constants.ts +++ b/course-matrix/backend/src/constants/constants.ts @@ -36,7 +36,8 @@ export const TEST_NOTIFICATIONS = false; // Note: month index in date constructor is 0 indexed (0 - 11) export const TEST_DATE_NOW = new Date(2025, 4, 14, 8, 45, 1); -// Set minimum results wanted for a similarity search on the associated namespace. +// Set minimum results wanted for a similarity search on the associated +// namespace. export const namespaceToMinResults = new Map(); namespaceToMinResults.set("courses_v3", 16); namespaceToMinResults.set("offerings", 16); // Typically, more offering info is wanted. diff --git a/course-matrix/backend/unit-tests/canInsert.test.ts b/course-matrix/backend/unit-tests/canInsert.test.ts new file mode 100644 index 00000000..0654ae1e --- /dev/null +++ b/course-matrix/backend/unit-tests/canInsert.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { Offering } from "../src/types/generatorTypes"; +import { + canInsert, + canInsertList, + createOffering, +} from "../src/utils/generatorHelpers"; + +describe("canInsert function", () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + + it("should return true if there is no overlap with existing offerings", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "12:00:00", + end: "13:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return false if there is an overlap with an existing offering", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "09:30:00", + end: "10:30:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(false); // There is an overlap with offering1, should return false + }); + + it("should return true if the new offering starts after the last one ends", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "13:00:00", + end: "14:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return true if the new offering ends before the first one starts", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "07:00:00", + end: "08:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return false if the new offering is completely inside an existing one", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "09:30:00", + end: "09:45:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(false); // Overlaps with offering1, should return false + }); + + it("should return true if the day is different (no overlap)", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "TU", + start: "09:00:00", + end: "10:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // Different day, no overlap + }); + + it("special case", async () => { + const toInsert: Offering = createOffering({ + id: 1069, + course_id: 1271, + day: "TH", + start: "05:00:00", + end: "17:00:00", + }); + const offering11: Offering = createOffering({ + id: 414, + course_id: 337, + day: "TU", + start: "15:00:00", + end: "16:00:00", + }); + const offering12: Offering = createOffering({ + id: 415, + course_id: 337, + day: "TH", + start: "15:00:00", + end: "17:00:00", + }); + const offering13: Offering = createOffering({ + id: 1052, + course_id: 1271, + day: "TU", + start: "10:00:00", + end: "11:00:00", + }); + const offering14: Offering = createOffering({ + id: 1053, + course_id: 1271, + day: "TU", + start: "09:00:00", + end: "11:00:00", + }); + const curList: Offering[] = [ + offering11, + offering12, + offering13, + offering14, + ]; + + const result = await canInsertList([toInsert], curList); + + expect(result).toBe(false); // Special bug-causing case + }); +}); diff --git a/course-matrix/backend/unit-tests/getFrequencyTable.test.ts b/course-matrix/backend/unit-tests/getFrequencyTable.test.ts new file mode 100644 index 00000000..b5aedade --- /dev/null +++ b/course-matrix/backend/unit-tests/getFrequencyTable.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { + createOffering, + getFrequencyTable, +} from "../src/utils/generatorHelpers"; +import { Offering } from "../src/types/generatorTypes"; + +describe("getFrequencyTable", () => { + test("should return a frequency map of days", () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "TU", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "TU", + start: "11:00:00", + end: "12:00:00", + }); + const offering4: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + const offering5: Offering = createOffering({ + id: 5, + course_id: 105, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + const offering6: Offering = createOffering({ + id: 6, + course_id: 106, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + const offering7: Offering = createOffering({ + id: 7, + course_id: 107, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + + const result = getFrequencyTable([ + offering1, + offering2, + offering3, + offering4, + offering5, + offering6, + offering7, + ]); + + expect(result.get("MO")).toBe(2); + expect(result.get("TU")).toBe(2); + expect(result.get("WE")).toBe(3); + expect(result.get("TH")).toBeUndefined(); // Day not in data + expect(result.get("FR")).toBeUndefined(); // Day not in data + expect(result.size).toBe(3); + }); + + test("should return an empty map for an empty array", () => { + const result = getFrequencyTable([]); + expect(result.size).toBe(0); + }); +}); diff --git a/course-matrix/backend/unit-tests/getMinHourDay.test.ts b/course-matrix/backend/unit-tests/getMinHourDay.test.ts new file mode 100644 index 00000000..5703db76 --- /dev/null +++ b/course-matrix/backend/unit-tests/getMinHourDay.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { Offering } from "../src/types/generatorTypes"; +import { + createOffering, + getMinHour, + getMinHourDay, +} from "../src/utils/generatorHelpers"; + +describe("getMinHourDay function", () => { + it("Back to back to back courses", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + const schedule: Offering[] = [offering1, offering2, offering3]; + + const result = getMinHourDay(schedule, 0); + + expect(result).toBe(true); + }); + + it("courses that has a max gap of 4 hours", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "15:00:00", + end: "16:00:00", + }); + const schedule: Offering[] = [offering3, offering2, offering1]; + + const result = getMinHourDay(schedule, 3); + + expect(result).toBe(false); + }); + + it("only 1 offering in list, return 0", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const schedule: Offering[] = [offering1]; + + const result = getMinHourDay(schedule, 23); + + expect(result).toBe(true); + }); + + it("getMinHour test", async () => { + const arr_day = [ + "MO", + "MO", + "TU", + "TH", + "FR", + "MO", + "TU", + "TH", + "MO", + "MO", + ]; + const arr_start = [ + "09:00:00", + "10:00:00", + "09:00:00", + "12:00:00", + "13:00:00", + "12:00:00", + "14:00:00", + "16:00:00", + "13:00:00", + "15:00:00", + ]; + const arr_end = [ + "10:00:00", + "11:00:00", + "10:00:00", + "15:00:00", + "16:00:00", + "13:00:00", + "19:00:00", + "18:00:00", + "14:00:00", + "18:00:00", + ]; + const schedule: Offering[] = []; + for (let i = 0; i < 10; i++) { + schedule.push( + createOffering({ + id: i, + course_id: 100 + i, + day: arr_day[i], + start: arr_start[i], + end: arr_end[i], + }), + ); + } + + const result = getMinHour(schedule, 4); + + expect(result).toEqual(true); + }); + + it("getMinHour test 2", async () => { + const arr_day = [ + "MO", + "MO", + "TU", + "TH", + "FR", + "MO", + "TU", + "TH", + "MO", + "MO", + ]; + const arr_start = [ + "09:00:00", + "10:00:00", + "09:00:00", + "12:00:00", + "13:00:00", + "12:00:00", + "14:00:00", + "16:00:00", + "13:00:00", + "15:00:00", + ]; + const arr_end = [ + "10:00:00", + "11:00:00", + "10:00:00", + "15:00:00", + "16:00:00", + "13:00:00", + "19:00:00", + "18:00:00", + "14:00:00", + "18:00:00", + ]; + const schedule: Offering[] = []; + for (let i = 0; i < 10; i++) { + schedule.push( + createOffering({ + id: i, + course_id: 100 + i, + day: arr_day[i], + start: arr_start[i], + end: arr_end[i], + }), + ); + } + + const result = getMinHour(schedule, 3); + + expect(result).toEqual(false); + }); +}); diff --git a/course-matrix/backend/unit-tests/isValidOffering.test.ts b/course-matrix/backend/unit-tests/isValidOffering.test.ts new file mode 100644 index 00000000..f994bba6 --- /dev/null +++ b/course-matrix/backend/unit-tests/isValidOffering.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { + Offering, + Restriction, + RestrictionType, +} from "../src/types/generatorTypes"; +import { createOffering, isValidOffering } from "../src/utils/generatorHelpers"; + +describe("isValidOffering", () => { + const sampleOffering: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + + test("should allow offering if there are no restrictions", () => { + expect(isValidOffering(sampleOffering, [])).toBe(true); + }); + + test("should allow offering if all restrictions are disabled", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBefore, + days: ["MO"], + startTime: "", + endTime: "09:00:00", + disabled: true, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(true); + }); + + test("should reject offering if it starts before restriction start time", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBefore, + days: ["MO"], + startTime: "", + endTime: "11:00:00", + disabled: false, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if it ends after restriction end time", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictAfter, + days: ["MO"], + startTime: "10:30:00", + endTime: "", + disabled: false, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if it is within restricted time range", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBetween, + days: ["MO"], + startTime: "09:00:00", + endTime: "12:00:00", + disabled: false, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if the day is restricted", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictDay, + days: ["MO"], + startTime: "", + endTime: "", + disabled: false, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should allow offering if the day is not restricted", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictDay, + days: ["TU"], + startTime: "", + endTime: "", + disabled: false, + numDays: 0, + maxGap: 24, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(true); + }); +}); diff --git a/course-matrix/backend/unit-tests/restrictionsController.test.ts b/course-matrix/backend/unit-tests/restrictionsController.test.ts new file mode 100644 index 00000000..770c8f7c --- /dev/null +++ b/course-matrix/backend/unit-tests/restrictionsController.test.ts @@ -0,0 +1,551 @@ +import request from "supertest"; +import restrictionsController from "../src/controllers/restrictionsController"; +import { Request, Response, NextFunction } from "express"; +import { + jest, + describe, + test, + expect, + beforeEach, + afterAll, +} from "@jest/globals"; +import { authHandler } from "../src/middleware/authHandler"; +import { supabase } from "../src/db/setupDb"; +import { + instanceOfErrorResponse, + Json, +} from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import app from "../src/index"; +import { server } from "../src/index"; +import { errorConverter } from "../src/middleware/errorHandler"; + +//Handle AI import from index.ts +jest.mock("@ai-sdk/openai", () => ({ + createOpenAI: jest.fn(() => ({ + chat: jest.fn(), + })), +})); + +jest.mock("ai", () => ({ + streamText: jest.fn(() => + Promise.resolve({ pipeDataStreamToResponse: jest.fn() }), + ), +})); + +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: jest.fn(() => ({ + Index: jest.fn(() => ({ + query: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + })), + })), +})); + +jest.mock("node-cron", () => ({ + schedule: jest.fn(), // Mock the `schedule` function +})); + +afterAll(async () => { + server.close(); +}); + +// Function to create authenticated session dynamically based as provided user_id +const mockAuthHandler = (user_id: string) => { + return (req: Request, res: Response, next: NextFunction) => { + (req as any).user = { id: user_id }; // Inject user_id dynamically + next(); + }; +}; + +// Mock authHandler globally +jest.mock("../src/middleware/authHandler", () => ({ + authHandler: jest.fn() as jest.MockedFunction, +})); + +// Mock timetables dataset +const mockRestriction = { + id: 1, + restriction_type: "Restrict Between", + user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", + start_time: "13:30:00", + end_time: "14:30:00", + days: ["MO", "TUE"], + disabled: false, +}; + +const mockRestriction2 = { + id: 1, + calendar_id: 1, + restriction_type: "Restrict Between", + user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", + start_time: "13:30:00", + end_time: "14:30:00", + days: ["MO", "TUE"], + disabled: false, +}; + +const mockTimetables1 = { + id: 1, + user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", +}; + +const mockTimetables2 = { + id: 1, + user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", +}; + +// Spy on the createRestriction method +jest + .spyOn(restrictionsController, "createRestriction") + .mockImplementation(restrictionsController.createRestriction); + +// Spy on the createTimetable method +jest + .spyOn(restrictionsController, "getRestriction") + .mockImplementation(restrictionsController.getRestriction); + +// Spy on the updateTimetable method +jest + .spyOn(restrictionsController, "updateRestriction") + .mockImplementation(restrictionsController.updateRestriction); + +// Spy on the deleteTimetable method +jest + .spyOn(restrictionsController, "deleteRestriction") + .mockImplementation(restrictionsController.deleteRestriction); + +// Mock data set response to qeury +jest.mock("../src/db/setupDb", () => ({ + supabase: { + //Mock return from schema, from and select to chain the next query command + schema: jest.fn().mockReturnThis(), + from: jest.fn().mockImplementation((key, value) => { + if (key === "timetables") { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockImplementation((key, value) => { + //Each test case is codded by the user_id in session + //DB response 3: Combine .eq and .maybeSingle to signify that the return value could be single: Return non null value + if ( + key === "user_id" && + value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { data: null, error: null }; + }), + }; + } + //DB response 4: Combine .eq and .maybeSingle to signify that the return value could be single: Return null value + if ( + key === "user_id" && + value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables1, error: null }; + }), + }; + } + + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables2, error: null }; + }), + }; + } + }), + }; + } + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockImplementation((key, value) => { + if ( + key === "user_id" && + value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { + data: null, + error: null, + }; + }), + }; + } + }), + }; + } + if ( + key === "user_id" && + value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { data: mockRestriction, error: null }; + } + }), + }; + } + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockRestriction2, error: null }; + }), + }; + } + }), + }; + } + return { data: null, error: null }; + }), + insert: jest.fn().mockImplementation((data: Json) => { + //DB response 5: Create timetable successfully, new timetable data is responded + if ( + data && + data[0].user_id === + "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + select: jest.fn().mockImplementation(() => { + // Return the input data when select is called + return { data: data, error: null }; // Return the data passed to insert + }), + }; + } + //DB response 6: Create timetable uncessfully, return error.message + return { + select: jest.fn().mockImplementation(() => { + return { + data: null, + error: { message: "Fail to create timetable" }, + }; + }), + }; + }), + update: jest.fn().mockImplementation((updatedata: Json) => { + //DB response 7: Timetable updated successfully, db return updated data in response + if (updatedata && updatedata.start_time === "09:00:00.000Z") { + return { + eq: jest.fn().mockReturnThis(), + select: jest.fn().mockImplementation(() => { + return { data: updatedata, error: null }; + }), + }; + } + //DB response 8: Update timetable uncessfully, return error.message + return { data: null, error: { message: "Fail to update timetable" } }; + }), + delete: jest.fn().mockImplementation(() => { + //DB response 9: Delete timetable successfully + return { + eq: jest.fn().mockImplementation((key, value) => { + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), + data: null, + error: null, + }; + } + return { + data: null, + error: { message: "Uncessful restriction delete" }, + }; + }), + }; + }), + }; + }), + select: jest.fn().mockReturnThis(), + // Mock db response to .eq query command + eq: jest.fn().mockReturnThis(), + // Mock db response to .insert query command + insert: jest.fn().mockReturnThis(), + + // Mock db response to .update query command + update: jest.fn().mockImplementation((updatedata: Json) => {}), + // Mock db response to .delete query command + delete: jest.fn().mockReturnThis(), + }, +})); + +//Test block 1: Get endpoint +describe("GET /api/timetables/restrictions/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return timetables", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser04-f84fd0da-d775-4424-ad88-d9675282453c"), + ); + + const response = await request(app).get("/api/timetables/restrictions/1"); + + // Check database interaction and response + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(mockRestriction); + }); + + test("should return 404 if no timetables found", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser03-f84fd0da-d775-4424-ad88-d9675282453c"), + ); + + const response = await request(app).get("/api/timetables/restrictions/1"); + // Verify the response status and error message + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 2: POST endpoint +describe("POST /api/timetables/restrictions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return error code 400 and error message: 'calendar id is required'", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = {}; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "calendar id is required", + }); + }); + + test("should return error code 400 and error message: 'Start time or end time must be provided'", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = { + calendar_id: "1", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "Start time or end time must be provided", + }); + }); + + test("should create a new timetable given calendar_id, start_time and end_time", async () => { + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const newRestriction = { + calendar_id: "1", + start_time: "2025-03-04T09:00:00.000Z", + end_time: "2025-03-04T10:00:00.000Z", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newRestriction); + // Check database interaction and response + + expect(response.statusCode).toBe(201); + expect(response.body).toEqual([ + { + user_id, + calendar_id: "1", + start_time: "09:00:00.000Z", + end_time: "10:00:00.000Z", + }, + ]); + }); + + test("should return error code 404 and error message: Calendar id not found", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newRestriction = { + calendar_id: "1", + start_time: "2025-03-04T09:00:00.000Z", + end_time: "2025-03-04T10:00:00.000Z", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newRestriction); + // Check database interaction and response + + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 3: Put endpoint +describe("PUT /api/timetables/restrictions/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test("should return error code 400 and message: calendar id is required ", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "calendar id is required", + }); + }); + + test("should update the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1?calendar_id=1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + start_time: "09:00:00.000Z", + }); + }); + + test("should return error code 404 and message: Restriction id does not exist", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1?calendar_id=1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Restriction id does not exist", + }); + }); +}); + +//Test block 4: Delete endpoint +describe("DELETE /api/timetables/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should delete the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete( + "/api/timetables/restrictions/1?calendar_id=1", + ); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + message: "Restriction successfully deleted", + }); + }); + + test("should return error code 404 and message: Calendar id not found and id not found", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete( + "/api/timetables/restrictions/1?calendar_id=1", + ); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Restriction id does not exist", + }); + }); +});