diff --git a/.github/workflows/backend-testing.yml b/.github/workflows/backend-testing.yml index 0d0b1f3f..ce7600e0 100644 --- a/.github/workflows/backend-testing.yml +++ b/.github/workflows/backend-testing.yml @@ -29,6 +29,12 @@ jobs: if: '! github.event.pull_request.draft' runs-on: [self-hosted, Linux, X64] + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + strategy: matrix: node-version: [22.x] @@ -42,4 +48,16 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run test:unit -w backend + - run: npm run build + - run: npm run test:coverage -w backend + - name: 'Report Backend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Backend' + json-summary-path: './backend/coverage/coverage-summary.json' + json-final-path: './backend/coverage/coverage-final.json' + vite-config-path: './backend/vitest.config.ts' + file-coverage-mode: all diff --git a/.github/workflows/frontend-testing.yml b/.github/workflows/frontend-testing.yml index ff7bde4d..053e66c3 100644 --- a/.github/workflows/frontend-testing.yml +++ b/.github/workflows/frontend-testing.yml @@ -38,6 +38,12 @@ jobs: if: '! github.event.pull_request.draft' runs-on: [self-hosted, Linux, X64] + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + strategy: matrix: node-version: [22.x] @@ -51,4 +57,16 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run test:unit -w frontend + - run: npm run build + - run: npm run test:coverage -w frontend + - name: 'Report Frontend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Frontend' + json-summary-path: './frontend/coverage/coverage-summary.json' + json-final-path: './frontend/coverage/coverage-final.json' + vite-config-path: './frontend/vitest.config.ts' + file-coverage-mode: all diff --git a/backend/package.json b/backend/package.json index dd4c2cbf..7943d61d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "format-check": "prettier --check src/", "lint": "eslint . --fix", "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", - "test:unit": "vitest --run" + "test:unit": "vitest --run", + "test:coverage": "vitest --run --coverage.enabled true" }, "dependencies": { "@mikro-orm/core": "6.4.12", diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 29142c49..cd281251 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -5,5 +5,17 @@ export default defineConfig({ environment: 'node', globals: true, testTimeout: 100000, + coverage: { + reporter: ['text', 'json-summary', 'json'], + // If you want a coverage reports even if your tests are failing, include the reportOnFailure option + reportOnFailure: true, + exclude: ['**/*config*', '**/tests/**', 'src/*.ts', '**/dist/**', '**/node_modules/**', 'src/logging/**', 'src/routes/**'], + thresholds: { + lines: 50, + branches: 50, + functions: 50, + statements: 50, + }, + }, }, }); diff --git a/frontend/package.json b/frontend/package.json index 507b8fa9..faeae3d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "format-check": "prettier --check src/", "lint": "eslint . --fix", "test:unit": "vitest --run", + "test:coverage": "vitest --run --coverage.enabled true", "test:e2e": "playwright test" }, "dependencies": { diff --git a/frontend/tests/controllers/assignments-controller.test.ts b/frontend/tests/controllers/assignments-controller.test.ts new file mode 100644 index 00000000..7f3f85e0 --- /dev/null +++ b/frontend/tests/controllers/assignments-controller.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AssignmentController } from "../../src/controllers/assignments"; + +describe("AssignmentController Tests", () => { + let controller: AssignmentController; + + beforeEach(() => { + controller = new AssignmentController("8764b861-90a6-42e5-9732-c0d9eb2f55f9"); // Example class ID + }); + + it("should fetch all assignments", async () => { + const result = await controller.getAll(true); + expect(result).toHaveProperty("assignments"); + expect(Array.isArray(result.assignments)).toBe(true); + expect(result.assignments.length).toBeGreaterThan(0); + }); + + it("should fetch an assignment by number", async () => { + const assignmentNumber = 21000; // Example assignment ID + const result = await controller.getByNumber(assignmentNumber); + expect(result).toHaveProperty("assignment"); + expect(result.assignment).toHaveProperty("id", assignmentNumber); + }); + + it("should update an existing assignment", async () => { + const assignmentNumber = 21000; + const updatedData = { title: "Updated Assignment Title" }; + const result = await controller.updateAssignment(assignmentNumber, updatedData); + expect(result).toHaveProperty("assignment"); + expect(result.assignment).toHaveProperty("id", assignmentNumber); + expect(result.assignment).toHaveProperty("title", updatedData.title); + }); + + it("should fetch submissions for an assignment", async () => { + const assignmentNumber = 21000; + const result = await controller.getSubmissions(assignmentNumber, true); + expect(result).toHaveProperty("submissions"); + expect(Array.isArray(result.submissions)).toBe(true); + }); + + it("should fetch questions for an assignment", async () => { + const assignmentNumber = 21000; + const result = await controller.getQuestions(assignmentNumber, true); + expect(result).toHaveProperty("questions"); + expect(Array.isArray(result.questions)).toBe(true); + }); + + it("should fetch groups for an assignment", async () => { + const assignmentNumber = 21000; + const result = await controller.getGroups(assignmentNumber, true); + expect(result).toHaveProperty("groups"); + expect(Array.isArray(result.groups)).toBe(true); + }); + + it("should handle fetching a non-existent assignment", async () => { + const assignmentNumber = 99999; // Non-existent assignment ID + await expect(controller.getByNumber(assignmentNumber)).rejects.toThrow(); + }); + + it("should handle deleting a non-existent assignment", async () => { + const assignmentNumber = 99999; // Non-existent assignment ID + await expect(controller.deleteAssignment(assignmentNumber)).rejects.toThrow(); + }); +}); diff --git a/frontend/tests/controllers/classes-controller.test.ts b/frontend/tests/controllers/classes-controller.test.ts new file mode 100644 index 00000000..8768aee8 --- /dev/null +++ b/frontend/tests/controllers/classes-controller.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { ClassController } from "../../src/controllers/classes"; + +describe("Test controller classes", () => { + it("Get classes", async () => { + const controller = new ClassController(); + const data = await controller.getAll(true); + expect(data.classes).to.have.length.greaterThan(0); + }); +}); diff --git a/frontend/tests/controllers/groups.controller.test.ts b/frontend/tests/controllers/groups.controller.test.ts new file mode 100644 index 00000000..cb7b9d75 --- /dev/null +++ b/frontend/tests/controllers/groups.controller.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { GroupController } from "../../src/controllers/groups"; + +describe("Test controller groups", () => { + it("Get groups", async () => { + const classId = "8764b861-90a6-42e5-9732-c0d9eb2f55f9"; + const assignmentNumber = 21000; + + const controller = new GroupController(classId, assignmentNumber); + const data = await controller.getAll(true); + expect(data.groups).to.have.length.greaterThan(0); + }); +}); diff --git a/frontend/tests/controllers/learning-paths-controller.test.ts b/frontend/tests/controllers/learning-paths-controller.test.ts new file mode 100644 index 00000000..28e4cda2 --- /dev/null +++ b/frontend/tests/controllers/learning-paths-controller.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { LearningPathController } from "../../src/controllers/learning-paths"; +import { Language } from "../../src/data-objects/language"; + +describe("Test controller learning paths", () => { + let controller: LearningPathController; + + beforeEach(async () => { + controller = new LearningPathController(); + }); + + it("Can search for learning paths", async () => { + const data = await controller.search("kiks", Language.Dutch); + expect(data).to.have.length.greaterThan(0); + }); + + it("Can get learning path by id", async () => { + const data = await controller.getAllByTheme("kiks"); + expect(data).to.have.length.greaterThan(0); + }); +}); diff --git a/frontend/tests/controllers/student-controller.test.ts b/frontend/tests/controllers/student-controller.test.ts index 89c8224e..925cf34d 100644 --- a/frontend/tests/controllers/student-controller.test.ts +++ b/frontend/tests/controllers/student-controller.test.ts @@ -1,19 +1,39 @@ import { StudentController } from "../../src/controllers/students"; -import { expect, it, describe, afterAll, beforeAll } from "vitest"; -import { setup, teardown } from "../setup-backend.js"; +import { beforeEach, describe, expect, it, test } from "vitest"; describe("Test controller students", () => { - beforeAll(async () => { - await setup(); - }); + let controller: StudentController; - afterAll(async () => { - await teardown(); + beforeEach(async () => { + controller = new StudentController(); }); it("Get students", async () => { - const controller = new StudentController(); const data = await controller.getAll(true); expect(data.students).to.have.length.greaterThan(0); }); + + it("Get student by username", async () => { + const username = "testleerling1"; + const data = await controller.getByUsername(username); + expect(data.student.username).to.equal(username); + }); +}); + +const controller = new StudentController(); + +test.each([ + { username: "Noordkaap", firstName: "Stijn", lastName: "Meuris" }, + { username: "DireStraits", firstName: "Mark", lastName: "Knopfler" }, + { username: "Tool", firstName: "Maynard", lastName: "Keenan" }, + { username: "SmashingPumpkins", firstName: "Billy", lastName: "Corgan" }, + { username: "PinkFloyd", firstName: "David", lastName: "Gilmoure" }, + { username: "TheDoors", firstName: "Jim", lastName: "Morisson" }, + // ⚠️ Deze mag niet gebruikt worden in elke test! + { username: "Nirvana", firstName: "Kurt", lastName: "Cobain" }, + // Makes sure when logged in as leerling1, there exists a corresponding user + { username: "testleerling1", firstName: "Gerald", lastName: "Schmittinger" }, +])("Get classes of student", async (student) => { + const data = await controller.getClasses(student.username, true); + expect(data.classes).to.have.length.greaterThan(0); }); diff --git a/frontend/tests/controllers/submissions-controller.test.ts b/frontend/tests/controllers/submissions-controller.test.ts new file mode 100644 index 00000000..450ddd7a --- /dev/null +++ b/frontend/tests/controllers/submissions-controller.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { SubmissionController } from "../../src/controllers/submissions"; +import { Language } from "../../src/data-objects/language"; + +describe("Test controller submissions", () => { + it("Get submission by number", async () => { + const hruid = "id03"; + const classId = "8764b861-90a6-42e5-9732-c0d9eb2f55f9"; + const controller = new SubmissionController(hruid); + + const data = await controller.getByNumber(Language.English, 1, classId, 1, 1, 1); + + expect(data.submission).to.have.property("submissionNumber"); + }); +}); diff --git a/frontend/tests/controllers/teacher-controller.test.ts b/frontend/tests/controllers/teacher-controller.test.ts new file mode 100644 index 00000000..12d81350 --- /dev/null +++ b/frontend/tests/controllers/teacher-controller.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { TeacherController } from "../../src/controllers/teachers"; + +describe("Test controller teachers", () => { + let controller: TeacherController; + + beforeEach(async () => { + controller = new TeacherController(); + }); + + it("Get all teachers", async () => { + const data = await controller.getAll(true); + expect(data.teachers).to.have.length.greaterThan(0); + expect(data.teachers[0]).to.have.property("username"); + expect(data.teachers[0]).to.have.property("firstName"); + expect(data.teachers[0]).to.have.property("lastName"); + }); + + it("Get teacher by username", async () => { + const username = "testleerkracht1"; + const data = await controller.getByUsername(username); + expect(data.teacher.username).to.equal(username); + expect(data.teacher).to.have.property("firstName"); + expect(data.teacher).to.have.property("lastName"); + }); + + it("Get teacher by non-existent username", async () => { + const username = "nonexistentuser"; + await expect(controller.getByUsername(username)).rejects.toThrow(); + }); + + it("Handle deletion of non-existent teacher", async () => { + const username = "nonexistentuser"; + await expect(controller.deleteTeacher(username)).rejects.toThrow(); + }); + + it("Get classes for a teacher", async () => { + const username = "testleerkracht1"; + const data = await controller.getClasses(username, true); + expect(data.classes).to.have.length.greaterThan(0); + expect(data.classes[0]).to.have.property("id"); + expect(data.classes[0]).to.have.property("displayName"); + }); + + it("Get students for a teacher", async () => { + const username = "testleerkracht1"; + const data = await controller.getStudents(username, true); + expect(data.students).to.have.length.greaterThan(0); + expect(data.students[0]).to.have.property("username"); + expect(data.students[0]).to.have.property("firstName"); + expect(data.students[0]).to.have.property("lastName"); + }); +}); diff --git a/frontend/tests/controllers/theme-controller.test.ts b/frontend/tests/controllers/theme-controller.test.ts new file mode 100644 index 00000000..e58fa10e --- /dev/null +++ b/frontend/tests/controllers/theme-controller.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ThemeController } from "../../src/controllers/themes"; + +describe("ThemeController Tests", () => { + let controller: ThemeController; + + beforeEach(() => { + controller = new ThemeController(); + }); + + it("should fetch all themes", async () => { + const result = await controller.getAll(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("key"); + expect(result[0]).toHaveProperty("title"); + expect(result[0]).toHaveProperty("description"); + expect(result[0]).toHaveProperty("image"); + }); + + it("should fetch all themes filtered by language", async () => { + const language = "en"; + const result = await controller.getAll(language); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + result.forEach((theme) => { + expect(theme).toHaveProperty("key"); + expect(theme).toHaveProperty("title"); + expect(theme).toHaveProperty("description"); + expect(theme).toHaveProperty("image"); + }); + }); + + it("should fetch HRUIDs by theme key", async () => { + const themeKey = "kiks"; + const result = await controller.getHruidsByKey(themeKey); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + result.forEach((hruid) => { + expect(typeof hruid).toBe("string"); + }); + }); + + it("should handle fetching HRUIDs for a non-existent theme key", async () => { + const themeKey = "nonexistent"; + await expect(controller.getHruidsByKey(themeKey)).rejects.toThrow(); + }); +}); diff --git a/frontend/tests/utils/assingment-rules.test.ts b/frontend/tests/utils/assingment-rules.test.ts new file mode 100644 index 00000000..3b6e5bfd --- /dev/null +++ b/frontend/tests/utils/assingment-rules.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + assignmentTitleRules, + classRules, + deadlineRules, + descriptionRules, + learningPathRules, +} from "../../src/utils/assignment-rules"; + +describe("Validation Rules", () => { + describe("assignmentTitleRules", () => { + it("should return true for a valid title", () => { + const result = assignmentTitleRules[0]("Valid Title"); + expect(result).toBe(true); + }); + + it("should return an error message for an empty title", () => { + const result = assignmentTitleRules[0](""); + expect(result).toBe("Title cannot be empty."); + }); + }); + + describe("learningPathRules", () => { + it("should return true for a valid learning path", () => { + const result = learningPathRules[0]({ hruid: "123", title: "Path Title" }); + expect(result).toBe(true); + }); + + it("should return an error message for an invalid learning path", () => { + const result = learningPathRules[0]({ hruid: "", title: "" }); + expect(result).toBe("You must select a learning path."); + }); + }); + + describe("classRules", () => { + it("should return true for a valid class", () => { + const result = classRules[0]("Class 1"); + expect(result).toBe(true); + }); + + it("should return an error message for an empty class", () => { + const result = classRules[0](""); + expect(result).toBe("You must select at least one class."); + }); + }); + + describe("deadlineRules", () => { + it("should return true for a valid future deadline", () => { + const futureDate = new Date(Date.now() + 1000 * 60 * 60).toISOString(); + const result = deadlineRules[0](futureDate); + expect(result).toBe(true); + }); + + it("should return an error message for a past deadline", () => { + const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString(); + const result = deadlineRules[0](pastDate); + expect(result).toBe("The deadline must be in the future."); + }); + + it("should return an error message for an invalid date", () => { + const result = deadlineRules[0]("invalid-date"); + expect(result).toBe("Invalid date or time."); + }); + + it("should return an error message for an empty deadline", () => { + const result = deadlineRules[0](""); + expect(result).toBe("You must set a deadline."); + }); + }); + + describe("descriptionRules", () => { + it("should return true for a valid description", () => { + const result = descriptionRules[0]("This is a valid description."); + expect(result).toBe(true); + }); + + it("should return an error message for an empty description", () => { + const result = descriptionRules[0](""); + expect(result).toBe("Description cannot be empty."); + }); + }); +}); diff --git a/frontend/tests/utils/deep-equals.test.ts b/frontend/tests/utils/deep-equals.test.ts new file mode 100644 index 00000000..8142b621 --- /dev/null +++ b/frontend/tests/utils/deep-equals.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { deepEquals } from "../../src/utils/deep-equals"; + +describe("deepEquals", () => { + it("should return true for identical primitive values", () => { + expect(deepEquals(1, 1)).toBe(true); + expect(deepEquals("test", "test")).toBe(true); + expect(deepEquals(true, true)).toBe(true); + }); + + it("should return false for different primitive values", () => { + expect(deepEquals(1, 2)).toBe(false); + expect(deepEquals("test", "other")).toBe(false); + expect(deepEquals(true, false)).toBe(false); + }); + + it("should return true for identical objects", () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 2 } }; + expect(deepEquals(obj1, obj2)).toBe(true); + }); + + it("should return false for different objects", () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { a: 1, b: { c: 3 } }; + expect(deepEquals(obj1, obj2)).toBe(false); + }); + + it("should return true for identical arrays", () => { + const arr1 = [1, 2, [3, 4]]; + const arr2 = [1, 2, [3, 4]]; + expect(deepEquals(arr1, arr2)).toBe(true); + }); + + it("should return false for different arrays", () => { + const arr1 = [1, 2, [3, 4]]; + const arr2 = [1, 2, [3, 5]]; + expect(deepEquals(arr1, arr2)).toBe(false); + }); + + it("should return false for objects and arrays compared", () => { + expect(deepEquals({ a: 1 }, [1])).toBe(false); + }); + + it("should return true for null compared to null", () => { + expect(deepEquals(null, null)).toBe(true); + }); + + it("should return false for null compared to an object", () => { + expect(deepEquals(null, {})).toBe(false); + }); + + it("should return false for undefined compared to null", () => { + expect(deepEquals(undefined, null)).toBe(false); + }); + + it("should return true for deeply nested identical structures", () => { + const obj1 = { a: [1, { b: 2, c: [3, 4] }] }; + const obj2 = { a: [1, { b: 2, c: [3, 4] }] }; + expect(deepEquals(obj1, obj2)).toBe(true); + }); + + it("should return false for deeply nested different structures", () => { + const obj1 = { a: [1, { b: 2, c: [3, 4] }] }; + const obj2 = { a: [1, { b: 2, c: [3, 5] }] }; + expect(deepEquals(obj1, obj2)).toBe(false); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 51fc91ab..a9beb299 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -9,6 +9,43 @@ export default mergeConfig( environment: "jsdom", exclude: [...configDefaults.exclude, "e2e/**"], root: fileURLToPath(new URL("./", import.meta.url)), + testTimeout: 100000, + coverage: { + reporter: ["text", "json-summary", "json"], + // If you want a coverage reports even if your tests are failing, include the reportOnFailure option + reportOnFailure: true, + exclude: [ + "**/*config*", + "**/tests/**", + "playwright-report/**", + "**/dist/**", + "**/e2e/**", + "**/*config*", + "**/node_modules/**", + + "src/main.ts", + "src/router/index.ts", + "src/utils/constants.ts", + + "**/*.d.ts", + + "src/**/*.vue", + "src/assets/**", + "src/i18n/**", + + "src/data-objects/**", + "src/exception/**", // TODO Might be useful to test later + "src/queries/**", // TODO Might be useful to test later + "src/views/learning-paths/gift-adapters/**", // TODO Might be useful to test later + "src/services/auth/**", // TODO Might be useful to test later + ], + thresholds: { + lines: 50, + branches: 50, + functions: 50, + statements: 50, + }, + }, /* * The test-backend server can be started for each test-file individually using `beforeAll(() => setup())`, @@ -16,6 +53,7 @@ export default mergeConfig( globalSetup: ["./tests/setup-backend.ts"], * In this project, the backend server is started for each test-file individually. */ + globalSetup: ["./tests/setup-backend.ts"], }, }), ); diff --git a/package.json b/package.json index 205fc5fd..3bf3a17c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "format-check": "npm run format-check --workspace=backend --workspace=common --workspace=frontend", "lint": "npm run lint --workspace=backend --workspace=common --workspace=frontend", "pretest:unit": "npm run build", - "test:unit": "npm run test:unit --workspace=backend --workspace=frontend" + "test:unit": "npm run test:unit --workspace=backend --workspace=frontend", + "test:coverage": "npm run test:coverage --workspace=backend --workspace=frontend" }, "workspaces": [ "backend",