diff --git a/course-matrix/backend/__tests__/auth.test.ts b/course-matrix/backend/__tests__/auth.test.ts
new file mode 100644
index 00000000..6332e27b
--- /dev/null
+++ b/course-matrix/backend/__tests__/auth.test.ts
@@ -0,0 +1,81 @@
+import request from "supertest";
+import { describe, expect, it, test } from "@jest/globals";
+import app from "../src/index";
+
+describe("Authentication API", () => {
+ // The unit tests below are currently commented out because they require a database connection.
+ // They will be uncommented out once all the necessary mocks are in place.
+
+ // describe('POST /auth/login', () => {
+ // it('should return 200 and a token for valid credentials', async () => {
+ // const response = await request(app)
+ // .post('/auth/login')
+ // .send({ username: '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({ username: 'invalidUser', password: 'wrongPassword' });
+ // expect(response.status).toBe(401);
+ // expect(response.body).toHaveProperty('error', 'Invalid credentials');
+ // });
+ // it('should return 400 if username or password is missing', async () => {
+ // const response = await request(app)
+ // .post('/auth/login')
+ // .send({ username: 'validUser' });
+ // expect(response.status).toBe(400);
+ // expect(response.body).toHaveProperty('error', 'Username and password are required');
+ // });
+ // });
+ // describe('POST /auth/register', () => {
+ // it('should return 201 and create a new user for valid input', async () => {
+ // const response = await request(app)
+ // .post('/auth/register')
+ // .send({ username: 'newUser', password: 'newPassword' });
+ // expect(response.status).toBe(201);
+ // expect(response.body).toHaveProperty('message', 'User registered successfully');
+ // });
+ // it('should return 400 if username is already taken', async () => {
+ // await request(app)
+ // .post('/auth/register')
+ // .send({ username: 'existingUser', password: 'password123' });
+ // const response = await request(app)
+ // .post('/auth/register')
+ // .send({ username: 'existingUser', password: 'password123' });
+ // expect(response.status).toBe(400);
+ // expect(response.body).toHaveProperty('error', 'Username is already taken');
+ // });
+ // it('should return 400 if username or password is missing', async () => {
+ // const response = await request(app)
+ // .post('/auth/register')
+ // .send({ username: '' });
+ // expect(response.status).toBe(400);
+ // expect(response.body).toHaveProperty('error', 'Username and password are required');
+ // });
+ // });
+ // describe('GET /auth/profile', () => {
+ // it('should return 200 and user profile for valid token', async () => {
+ // const loginResponse = await request(app)
+ // .post('/auth/login')
+ // .send({ username: 'validUser', password: 'validPassword' });
+ // const token = loginResponse.body.token;
+ // const response = await request(app)
+ // .get('/auth/profile')
+ // .set('Authorization', `Bearer ${token}`);
+ // expect(response.status).toBe(200);
+ // expect(response.body).toHaveProperty('username', 'validUser');
+ // });
+ // it('should return 401 if token is missing or invalid', async () => {
+ // const response = await request(app)
+ // .get('/auth/profile')
+ // .set('Authorization', 'Bearer invalidToken');
+ // expect(response.status).toBe(401);
+ // expect(response.body).toHaveProperty('error', 'Unauthorized');
+ // });
+ // });
+ it("template test", () => {
+ expect(2 + 3).toEqual(5);
+ });
+});
diff --git a/course-matrix/backend/__tests__/index.test.ts b/course-matrix/backend/__tests__/index.test.ts
index 874e1000..28f3930f 100644
--- a/course-matrix/backend/__tests__/index.test.ts
+++ b/course-matrix/backend/__tests__/index.test.ts
@@ -7,12 +7,3 @@ describe("Sum function", () => {
expect(2 + 3).toEqual(5);
});
});
-
-// Will finish the rest of the tests below in Sprint 3
-
-// describe("GET /auth/session", () => {
-// test("It should respond with 200 status", async () => {
-// const response = await request(app).get("/auth/session");
-// expect(response.statusCode).toBe(200);
-// });
-// });
diff --git a/course-matrix/frontend/__tests__/UserMenu.test.tsx b/course-matrix/frontend/__tests__/UserMenu.test.tsx
deleted file mode 100644
index fb07ec06..00000000
--- a/course-matrix/frontend/__tests__/UserMenu.test.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import configureStore from "redux-mock-store";
-import { UserMenu } from "../src/components/UserMenu";
-import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
-
-// const mockStore = configureStore([]);
-
-describe("UserMenu Component", () => {
- beforeEach(() => {
- localStorage.setItem(
- "userInfo",
- JSON.stringify({
- user: {
- id: "123",
- user_metadata: {
- username: "John Doe",
- email: "john.doe@example.com",
- },
- },
- }),
- );
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- test("check local storage", () => {
- expect(localStorage.getItem("userInfo")).not.toBeNull();
- });
-
- // Will finish the rest of the tests below in Sprint 3
-
- // test("opens edit account dialog", () => {
- // render(
- //
- //
- //
- //
- // ,
- // );
-
- // fireEvent.click(screen.getByText("John Doe"));
- // fireEvent.click(screen.getByText("Edit Account"));
-
- // expect(screen.getByText("Edit Account")).toBeInTheDocument();
- // expect(screen.getByLabelText("New User Name")).toBeInTheDocument();
- // });
-
- // test("opens delete account dialog", () => {
- // render(
- //
- //
- //
- //
- // ,
- // );
-
- // fireEvent.click(screen.getByText("John Doe"));
- // fireEvent.click(screen.getByText("Delete Account"));
-
- // expect(screen.getByText("Delete Account")).toBeInTheDocument();
- // expect(
- // screen.getByText(
- // "Are you sure you want to delete your account? This action cannot be undone.",
- // ),
- // ).toBeInTheDocument();
- // });
-
- // test("logs out user", () => {
- // render(
- //
- //
- //
- //
- // ,
- // );
-
- // fireEvent.click(screen.getByText("John Doe"));
- // fireEvent.click(screen.getByText("Logout"));
-
- // // Add assertions to check if the user is logged out
- // });
-});
diff --git a/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx b/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx
new file mode 100644
index 00000000..a7cb7171
--- /dev/null
+++ b/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx
@@ -0,0 +1,128 @@
+import "@testing-library/jest-dom/jest-globals";
+import "@testing-library/jest-dom";
+
+import { render, screen, fireEvent } from "@testing-library/react";
+import React from "react";
+import { MemoryRouter } from "react-router-dom";
+import { expect, test } from "@jest/globals";
+import App from "../../src/App";
+import SignupPage from "../../src/pages/Signup/SignUpPage";
+import LoginPage from "../../src/pages/Login/LoginPage";
+import Dashboard from "../../src/pages/Dashboard/Dashboard";
+import TimetableBuilder from "../../src/pages/TimetableBuilder/TimetableBuilder";
+import Home from "../../src/pages/Home/Home";
+
+test("typical flow for creating an account and logging in", () => {
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+
+ // Going to login page
+ expect(screen.getByText("Login")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Login"));
+
+ // Logging in with invalid credentials
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "" } });
+ fireEvent.change(screen.getByLabelText("Password"), {
+ target: { value: "password" },
+ });
+ fireEvent.click(screen.getByText("Login"));
+ expect(
+ screen.getByText("Login invalid. Please check your email or password."),
+ ).toBeInTheDocument();
+
+ // Going to signup page
+ fireEvent.click(screen.getByText("Sign up!"));
+ expect(screen.getByText("Sign up")).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "" } });
+ fireEvent.change(screen.getByLabelText("Password"), {
+ target: { value: "" },
+ });
+ fireEvent.click(screen.getByText("Sign up"));
+ expect(screen.getByText("Email is required")).toBeInTheDocument();
+ expect(screen.getByText("Password is required")).toBeInTheDocument();
+
+ // Creating an account
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "test@example.com" },
+ });
+ fireEvent.change(screen.getByLabelText("Password"), {
+ target: { value: "password123" },
+ });
+ fireEvent.click(screen.getByText("Sign up"));
+ expect(screen.getByText("Account created successfully!")).toBeInTheDocument();
+
+ // Logging in with valid credentials
+ fireEvent.change(screen.getByLabelText("Email"), {
+ target: { value: "test@example.com" },
+ });
+ fireEvent.change(screen.getByLabelText("Password"), {
+ target: { value: "password123" },
+ });
+ fireEvent.click(screen.getByText("Login"));
+
+ // Check if user is redirected to home page
+ expect(screen.getByText("Home Page")).toBeInTheDocument();
+});
+
+test("typical flow for creating a new timetable, adding courses, adding restrictions, saving the timetable, and editing the timetable", () => {
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+ render(, { wrapper: MemoryRouter });
+
+ expect(screen.getByText("My Timetables")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Create New"));
+
+ expect(screen.getByText("New Timetable")).toBeInTheDocument();
+
+ // Adding courses
+ fireEvent.click(screen.getByText("Choose meeting sections manually"));
+ fireEvent.click(screen.getByText("Search"));
+ fireEvent.click(screen.getByText("ACMB10H3"));
+ fireEvent.click(screen.getByText("ACMC01H3"));
+ fireEvent.click(screen.getByText("ACMD01H3"));
+ screen.getAllByText("No LEC Selected").forEach((element) => {
+ fireEvent.click(element);
+ fireEvent.click(screen.getByText("LEC 01"));
+ });
+
+ // Adding a restriction
+ fireEvent.click(screen.getByText("Add New"));
+ fireEvent.click(screen.getByText("Select a type"));
+ fireEvent.click(screen.getByText("Restrict Entire Day"));
+ fireEvent.click(screen.getByText("Tuesday"));
+ fireEvent.click(screen.getByText("Create"));
+
+ // Saving the timetable
+ fireEvent.click(screen.getByText("Create Timetable"));
+ fireEvent.change(screen.getByLabelText("Timetable Name"), {
+ target: { value: "Test Timetable Name" },
+ });
+ fireEvent.click(screen.getByText("Save"));
+
+ // Check if user is redirected to home page and timetable is saved
+ expect(screen.getByText("My Timetables")).toBeInTheDocument();
+ expect(screen.getByText("Test Timetable Name")).toBeInTheDocument();
+
+ // Editing the timetable by resetting all courses and restrictions
+ fireEvent.click(screen.getByText("Test Timetable Name"));
+ expect(screen.getByText("Edit Timetable")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Reset"));
+ expect(screen.queryByText("ACMB10H3")).toBeNull();
+ expect(screen.queryByText("ACMC01H3")).toBeNull();
+ expect(screen.queryByText("ACMD01H3")).toBeNull();
+ expect(screen.queryByText("Tuesday")).toBeNull();
+ fireEvent.click(screen.getByText("Update Timetable"));
+
+ // Check if user is redirected to home page and timetable is updated
+ expect(screen.getByText("My Timetables")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Test Timetable Name"));
+ expect(screen.queryByText("ACMB10H3")).toBeNull();
+ expect(screen.queryByText("ACMC01H3")).toBeNull();
+ expect(screen.queryByText("ACMD01H3")).toBeNull();
+ expect(screen.queryByText("Tuesday")).toBeNull();
+});
diff --git a/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx b/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx
new file mode 100644
index 00000000..9a97f01a
--- /dev/null
+++ b/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx
@@ -0,0 +1,196 @@
+import "@testing-library/jest-dom/jest-globals";
+import "@testing-library/jest-dom";
+
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { BrowserRouter } from "react-router-dom";
+import {
+ beforeEach,
+ describe,
+ vi,
+ Mock,
+ afterEach,
+ it,
+ expect,
+ MockedFunction,
+} from "vitest";
+import TimetableBuilder from "../../src/pages/TimetableBuilder/TimetableBuilder";
+import { useForm, UseFormWatch } from "react-hook-form";
+import { z } from "zod";
+import React from "react";
+
+vi.mock("react-hook-form", () => ({
+ useForm: vi.fn(),
+}));
+
+vi.mock("@/api/coursesApiSlice", () => ({
+ useGetCoursesQuery: vi.fn(() => ({ data: [], isLoading: false })),
+}));
+
+vi.mock("@/api/timetableApiSlice", () => ({
+ useGetTimetablesQuery: vi.fn(() => ({ data: [] })),
+}));
+
+vi.mock("@/api/eventsApiSlice", () => ({
+ useGetEventsQuery: vi.fn(() => ({ data: null })),
+}));
+
+vi.mock("@/api/offeringsApiSlice", () => ({
+ useGetOfferingsQuery: vi.fn(() => ({ data: [] })),
+}));
+
+vi.mock("@/api/restrictionsApiSlice", () => ({
+ useGetRestrictionsQuery: vi.fn(() => ({ data: [] })),
+}));
+
+vi.mock("@/utils/useDebounce", () => ({
+ useDebounceValue: (value: string) => value,
+}));
+
+describe("TimetableBuilder", () => {
+ const mockSetValue = vi.fn();
+ const mockHandleSubmit = vi.fn((fn) => fn);
+
+ beforeEach(() => {
+ (useForm as Mock).mockReturnValue({
+ control: {},
+ handleSubmit: mockHandleSubmit,
+ setValue: mockSetValue,
+ watch: vi.fn(() => {
+ const result: { unsubscribe: () => void } & any[] = [];
+ result.unsubscribe = () => {};
+ return result;
+ }) as unknown as UseFormWatch,
+ reset: vi.fn(),
+ getValues: vi.fn(() => []),
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the TimetableBuilder component", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/New Timetable/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/Pick a few courses you'd like to take/i),
+ ).toBeInTheDocument();
+ });
+
+ it("calls the reset function when the Reset button is clicked", () => {
+ const mockReset = vi.fn();
+ (useForm as MockedFunction).mockReturnValue({
+ reset: mockReset,
+ handleSubmit: mockHandleSubmit,
+ setValue: mockSetValue,
+ watch: vi.fn(() => {
+ const result: unknown = [];
+ result.unsubscribe = () => {};
+ return result;
+ }),
+ getValues: vi.fn(() => []),
+ });
+
+ render(
+
+
+ ,
+ );
+
+ const resetButton = screen.getByText(/Reset/i);
+ fireEvent.click(resetButton);
+
+ expect(mockReset).toHaveBeenCalled();
+ });
+
+ it("opens the custom settings modal when the Add new button is clicked", () => {
+ render(
+
+
+ ,
+ );
+
+ const addNewButton = screen.getByText(/\+ Add new/i);
+ fireEvent.click(addNewButton);
+
+ expect(screen.getByText(/Custom Settings/i)).toBeInTheDocument();
+ });
+
+ it("displays selected courses when courses are added", async () => {
+ const mockWatch = vi.fn(() => [
+ { id: 1, code: "CS101", name: "Introduction to Computer Science" },
+ ]);
+ (useForm as vi.Mock).mockReturnValue({
+ watch: mockWatch,
+ handleSubmit: mockHandleSubmit,
+ setValue: mockSetValue,
+ reset: vi.fn(),
+ getValues: vi.fn(() => []),
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Selected courses: 1/i)).toBeInTheDocument();
+ expect(screen.getByText(/CS101/i)).toBeInTheDocument();
+ });
+
+ it("removes a course when the remove button is clicked", async () => {
+ const mockWatch = vi.fn(() => [
+ { id: 1, code: "CS101", name: "Introduction to Computer Science" },
+ ]);
+ const mockSetValue = vi.fn();
+ (useForm as vi.Mock).mockReturnValue({
+ watch: mockWatch,
+ handleSubmit: mockHandleSubmit,
+ setValue: mockSetValue,
+ reset: vi.fn(),
+ getValues: vi.fn(() => [
+ { id: 1, code: "CS101", name: "Introduction to Computer Science" },
+ ]),
+ });
+
+ render(
+
+
+ ,
+ );
+
+ const removeButton = screen.getByRole("button", { name: /Remove/i });
+ fireEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(mockSetValue).toHaveBeenCalledWith("courses", []);
+ });
+ });
+
+ it("submits the form when the Generate button is clicked", () => {
+ const mockSubmit = vi.fn();
+ (useForm as vi.Mock).mockReturnValue({
+ handleSubmit: vi.fn((fn) => fn),
+ setValue: mockSetValue,
+ watch: vi.fn(() => []),
+ reset: vi.fn(),
+ getValues: vi.fn(() => []),
+ });
+
+ render(
+
+
+ ,
+ );
+
+ const generateButton = screen.getByText(/Generate/i);
+ fireEvent.click(generateButton);
+
+ expect(mockHandleSubmit).toHaveBeenCalled();
+ });
+});
diff --git a/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx b/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx
new file mode 100644
index 00000000..0e094802
--- /dev/null
+++ b/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx
@@ -0,0 +1,88 @@
+import "@testing-library/jest-dom/jest-globals";
+import "@testing-library/jest-dom";
+
+import configureStore from "redux-mock-store";
+import { UserMenu } from "../../src/components/UserMenu";
+import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Router } from "lucide-react";
+import React from "react";
+import { Provider } from "react-redux";
+
+const mockStore = configureStore([]);
+
+describe("UserMenu Component", () => {
+ beforeEach(() => {
+ localStorage.setItem(
+ "userInfo",
+ JSON.stringify({
+ user: {
+ id: "123",
+ user_metadata: {
+ username: "John Doe",
+ email: "john.doe@example.com",
+ },
+ },
+ }),
+ );
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ test("check local storage", () => {
+ expect(localStorage.getItem("userInfo")).not.toBeNull();
+ });
+
+ test("opens edit account dialog", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText("John Doe"));
+ fireEvent.click(screen.getByText("Edit Account"));
+
+ expect(screen.getByText("Edit Account")).toBeInTheDocument();
+ expect(screen.getByLabelText("New User Name")).toBeInTheDocument();
+ });
+
+ test("opens delete account dialog", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText("John Doe"));
+ fireEvent.click(screen.getByText("Delete Account"));
+
+ expect(screen.getByText("Delete Account")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Are you sure you want to delete your account? This action cannot be undone.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ test("logs out user", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText("John Doe"));
+ fireEvent.click(screen.getByText("Logout"));
+
+ expect(localStorage.getItem("userInfo")).toBeNull();
+ });
+});
diff --git a/course-matrix/frontend/jest.config.ts b/course-matrix/frontend/jest.config.ts
index 93fb9522..00bf9415 100644
--- a/course-matrix/frontend/jest.config.ts
+++ b/course-matrix/frontend/jest.config.ts
@@ -2,7 +2,7 @@ import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
- moduleNameMapper: { "\\.(css|scss)$": "identity-obj-proxy" },
+ moduleNameMapper: { "@/(.*)$": "/src/$1" },
// to obtain access to the matchers.
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
modulePaths: [""],
@@ -16,6 +16,12 @@ const config: Config = {
],
"^.+\\.(js|jsx)$": "babel-jest",
},
+ modulePathIgnorePatterns: [
+ "/dist/",
+ "/node_modules/",
+ "/__tests__/integration-tests/",
+ "/__tests__/unit-tests/",
+ ],
};
export default config;
diff --git a/course-matrix/frontend/package-lock.json b/course-matrix/frontend/package-lock.json
index a58e5947..7701f627 100644
--- a/course-matrix/frontend/package-lock.json
+++ b/course-matrix/frontend/package-lock.json
@@ -74,7 +74,8 @@
"ts-node": "^10.9.2",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
- "vite": "^6.0.5"
+ "vite": "^6.0.5",
+ "vitest": "^3.0.9"
}
},
"node_modules/@adobe/css-tools": {
@@ -3993,6 +3994,112 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz",
+ "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "3.0.9",
+ "@vitest/utils": "3.0.9",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz",
+ "integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "3.0.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz",
+ "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==",
+ "dev": true,
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz",
+ "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "3.0.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz",
+ "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "3.0.9",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz",
+ "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==",
+ "dev": true,
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz",
+ "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "3.0.9",
+ "loupe": "^3.1.3",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -4187,6 +4294,15 @@
"dequal": "^2.0.3"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/assistant-stream": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.0.21.tgz",
@@ -4475,6 +4591,15 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4543,6 +4668,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
+ "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
+ "dev": true,
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4604,6 +4745,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/check-error": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -4984,6 +5134,15 @@
}
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5205,6 +5364,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
+ "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
+ "dev": true
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5494,6 +5659,15 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -5565,6 +5739,15 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz",
+ "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -7634,6 +7817,12 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
+ "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
+ "dev": true
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -7661,6 +7850,15 @@
"lz-string": "bin/bin.js"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.17",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
+ "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -8909,6 +9107,21 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true
+ },
+ "node_modules/pathval": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
+ "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9825,6 +10038,12 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -9914,6 +10133,18 @@
"node": ">=8"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "node_modules/std-env": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz",
+ "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==",
+ "dev": true
+ },
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -10287,6 +10518,45 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true
+ },
+ "node_modules/tinypool": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
+ "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -10911,6 +11181,97 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz",
+ "integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==",
+ "dev": true,
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.0",
+ "es-module-lexer": "^1.6.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz",
+ "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "3.0.9",
+ "@vitest/mocker": "3.0.9",
+ "@vitest/pretty-format": "^3.0.9",
+ "@vitest/runner": "3.0.9",
+ "@vitest/snapshot": "3.0.9",
+ "@vitest/spy": "3.0.9",
+ "@vitest/utils": "3.0.9",
+ "chai": "^5.2.0",
+ "debug": "^4.4.0",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinypool": "^1.0.2",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0",
+ "vite-node": "3.0.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.0.9",
+ "@vitest/ui": "3.0.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -10989,6 +11350,22 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/course-matrix/frontend/package.json b/course-matrix/frontend/package.json
index 23872a66..9030101e 100644
--- a/course-matrix/frontend/package.json
+++ b/course-matrix/frontend/package.json
@@ -78,6 +78,7 @@
"ts-node": "^10.9.2",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
- "vite": "^6.0.5"
+ "vite": "^6.0.5",
+ "vitest": "^3.0.9"
}
}
diff --git a/course-matrix/frontend/tsconfig.json b/course-matrix/frontend/tsconfig.json
index fec8c8e5..586ee7ac 100644
--- a/course-matrix/frontend/tsconfig.json
+++ b/course-matrix/frontend/tsconfig.json
@@ -8,6 +8,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "jsx": "react-jsx",
+ "types": ["@testing-library/jest-dom"]
}
}
diff --git a/course-matrix/frontend/tsconfig.test.json b/course-matrix/frontend/tsconfig.test.json
index 05992782..dde8fcf3 100644
--- a/course-matrix/frontend/tsconfig.test.json
+++ b/course-matrix/frontend/tsconfig.test.json
@@ -6,6 +6,10 @@
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"jsx": "react-jsx",
"types": ["jest", "@testing-library/jest-dom"]
},