diff --git a/Backend/jest.config.js b/Backend/jest.config.js new file mode 100644 index 00000000..89ba304b --- /dev/null +++ b/Backend/jest.config.js @@ -0,0 +1,22 @@ +/** @type {import('jest').Config} */ +const config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + clearMocks: true, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/generated/**', + '!src/server.ts' + ], + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }] + } +}; + +module.exports = config; diff --git a/Backend/package-lock.json b/Backend/package-lock.json index 87a6afa3..7891b301 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -8,10 +8,10 @@ "name": "e-learning-backend", "version": "1.0.0", "dependencies": { + "@node-rs/bcrypt": "^1.10.7", "@pdf-lib/fontkit": "^1.1.1", "@prisma/client": "^5.22.0", "@tsoa/runtime": "^6.4.0", - "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.2", @@ -33,6 +33,7 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.10.5", @@ -1364,6 +1365,37 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2350,6 +2382,16 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2411,6 +2453,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -2427,6 +2479,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -2626,24 +2702,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@noble/hashes": { @@ -2659,6 +2727,259 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@node-rs/bcrypt": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz", + "integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/bcrypt-android-arm-eabi": "1.10.7", + "@node-rs/bcrypt-android-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-x64": "1.10.7", + "@node-rs/bcrypt-freebsd-x64": "1.10.7", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7", + "@node-rs/bcrypt-linux-arm64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-arm64-musl": "1.10.7", + "@node-rs/bcrypt-linux-x64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-x64-musl": "1.10.7", + "@node-rs/bcrypt-wasm32-wasi": "1.10.7", + "@node-rs/bcrypt-win32-arm64-msvc": "1.10.7", + "@node-rs/bcrypt-win32-ia32-msvc": "1.10.7", + "@node-rs/bcrypt-win32-x64-msvc": "1.10.7" + } + }, + "node_modules/@node-rs/bcrypt-android-arm-eabi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz", + "integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-android-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz", + "integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz", + "integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz", + "integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-freebsd-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz", + "integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz", + "integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz", + "integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz", + "integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz", + "integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz", + "integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-wasm32-wasi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz", + "integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz", + "integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz", + "integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz", + "integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3599,6 +3920,16 @@ "yarn": ">=1.9.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -3789,6 +4120,230 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4186,12 +4741,6 @@ "license": "(Unlicense OR Apache-2.0)", "optional": true }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -4228,18 +4777,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4317,26 +4854,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4527,20 +5044,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4888,15 +5391,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -5013,15 +5507,6 @@ "node": ">=12.20" } }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/color/node_modules/color-convert": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", @@ -5070,6 +5555,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -5117,12 +5603,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -5232,6 +5712,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5313,12 +5794,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5338,15 +5813,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6203,40 +6669,11 @@ "node": ">=14.14" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6263,27 +6700,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -6386,6 +6802,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -6419,6 +6836,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6429,6 +6847,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6538,12 +6957,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6592,19 +7005,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6696,6 +7096,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7910,30 +8311,6 @@ "yallist": "^3.0.2" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -8124,37 +8501,6 @@ "node": ">=8" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -8214,32 +8560,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8339,21 +8659,6 @@ "node": ">=4" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8377,19 +8682,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8427,6 +8719,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8584,6 +8877,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9096,22 +9390,6 @@ "node": ">=10" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9239,12 +9517,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9365,6 +9637,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/simple-update-notifier": { @@ -9737,41 +10010,6 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9913,12 +10151,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -10045,7 +10277,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/tsoa": { @@ -10301,22 +10533,6 @@ "@zxing/text-encoding": "0.9.0" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10353,15 +10569,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -10453,6 +10660,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/Backend/package.json b/Backend/package.json index 392d6b82..8f71c83c 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -44,6 +44,7 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.10.5", diff --git a/Backend/pnpm-lock.yaml b/Backend/pnpm-lock.yaml index f7f8d01b..a30e07ad 100644 --- a/Backend/pnpm-lock.yaml +++ b/Backend/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/jsonwebtoken': specifier: ^9.0.7 version: 9.0.10 @@ -119,7 +122,7 @@ importers: version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -615,6 +618,10 @@ packages: node-notifier: optional: true + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -623,6 +630,10 @@ packages: resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@29.7.0': resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -631,10 +642,18 @@ packages: resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/globals@29.7.0': resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/reporters@29.7.0': resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -648,6 +667,10 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@29.6.3': resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -668,6 +691,10 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -862,6 +889,9 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1115,6 +1145,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1433,6 +1466,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -1729,6 +1766,10 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -2118,6 +2159,10 @@ packages: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@29.7.0: resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2146,14 +2191,26 @@ packages: resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-pnp-resolver@1.2.3: resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -2167,6 +2224,10 @@ packages: resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-resolve-dependencies@29.7.0: resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2191,6 +2252,10 @@ packages: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@29.7.0: resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2584,6 +2649,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@5.22.0: resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} engines: {node: '>=16.13'} @@ -4027,6 +4096,8 @@ snapshots: - supports-color - ts-node + '@jest/diff-sequences@30.0.1': {} + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -4038,6 +4109,10 @@ snapshots: dependencies: jest-get-type: 29.6.3 + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + '@jest/expect@29.7.0': dependencies: expect: 29.7.0 @@ -4054,6 +4129,8 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + '@jest/get-type@30.1.0': {} + '@jest/globals@29.7.0': dependencies: '@jest/environment': 29.7.0 @@ -4063,6 +4140,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 22.19.5 + jest-regex-util: 30.0.1 + '@jest/reporters@29.7.0': dependencies: '@bcoe/v8-coverage': 0.2.3 @@ -4096,6 +4178,10 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.8 + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + '@jest/source-map@29.6.3': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4145,6 +4231,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.5 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4316,6 +4412,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@sinclair/typebox@0.34.48': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -4721,6 +4819,11 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest@30.0.0': + dependencies: + expect: 30.2.0 + pretty-format: 30.2.0 + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10': @@ -5116,6 +5219,8 @@ snapshots: ci-info@3.9.0: {} + ci-info@4.4.0: {} + cjs-module-lexer@1.4.3: {} cliui@8.0.1: @@ -5398,6 +5503,15 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + express-rate-limit@7.5.1(express@4.22.1): dependencies: express: 4.22.1 @@ -5872,6 +5986,13 @@ snapshots: jest-get-type: 29.6.3 pretty-format: 29.7.0 + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + jest-docblock@29.7.0: dependencies: detect-newline: 3.1.0 @@ -5923,6 +6044,13 @@ snapshots: jest-get-type: 29.6.3 pretty-format: 29.7.0 + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + jest-message-util@29.7.0: dependencies: '@babel/code-frame': 7.27.1 @@ -5935,18 +6063,38 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 '@types/node': 22.19.5 jest-util: 29.7.0 + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.19.5 + jest-util: 30.2.0 + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): optionalDependencies: jest-resolve: 29.7.0 jest-regex-util@29.6.3: {} + jest-regex-util@30.0.1: {} + jest-resolve-dependencies@29.7.0: dependencies: jest-regex-util: 29.6.3 @@ -6053,6 +6201,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.19.5 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + jest-validate@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -6444,6 +6601,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + prisma@5.22.0: dependencies: '@prisma/engines': 5.22.0 @@ -6797,7 +6960,7 @@ snapshots: ts-deepmerge@7.0.3: {} - ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -6813,9 +6976,9 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 + '@jest/types': 30.2.0 babel-jest: 29.7.0(@babel/core@7.28.5) - jest-util: 29.7.0 + jest-util: 30.2.0 tsconfig-paths@4.2.0: dependencies: diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index f10b99ca..46cc1028 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -24,9 +24,7 @@ export class AdminCourseApprovalController { @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') public async listPendingCourses(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await AdminCourseApprovalService.listPendingCourses(token); + return await AdminCourseApprovalService.listPendingCourses(request.user.id); } /** @@ -41,9 +39,7 @@ export class AdminCourseApprovalController { @Response('403', 'Forbidden - Admin only') @Response('404', 'Course not found') public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await AdminCourseApprovalService.getCourseDetail(token, courseId); + return await AdminCourseApprovalService.getCourseDetail(request.user.id, courseId); } /** @@ -62,10 +58,7 @@ export class AdminCourseApprovalController { @Request() request: any, @Path() courseId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); + return await AdminCourseApprovalService.approveCourse(request.user.id, courseId, undefined); } /** @@ -85,13 +78,10 @@ export class AdminCourseApprovalController { @Path() courseId: number, @Body() body: RejectCourseBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - // Validate body const { error } = RejectCourseValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); - return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); + return await AdminCourseApprovalService.rejectCourse(request.user.id, courseId, body.comment); } } diff --git a/Backend/src/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index a78c8d5a..6f715061 100644 --- a/Backend/src/controllers/AuditController.ts +++ b/Backend/src/controllers/AuditController.ts @@ -40,11 +40,6 @@ export class AuditController { @Query() page?: number, @Query() limit?: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await auditService.getLogs({ userId, action, @@ -72,11 +67,6 @@ export class AuditController { @Request() request: any, @Path() logId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - const log = await auditService.getLogById(logId); if (!log) { throw new ValidationError('Audit log not found'); @@ -94,11 +84,6 @@ export class AuditController { @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') public async getAuditStats(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await auditService.getStats(); } @@ -118,11 +103,6 @@ export class AuditController { @Path() entityType: string, @Path() entityId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await auditService.getEntityHistory(entityType, entityId); } @@ -142,11 +122,6 @@ export class AuditController { @Path() userId: number, @Query() limit?: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await auditService.getUserActivity(userId, limit || 50); } @@ -164,11 +139,6 @@ export class AuditController { @Request() request: any, @Query() days: number = 90 ): Promise<{ deleted: number; message: string }> { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - if (days < 6) { throw new ValidationError('Cannot delete logs newer than 6 days'); } diff --git a/Backend/src/controllers/AuthController.ts b/Backend/src/controllers/AuthController.ts index 96cb0673..b3c8e280 100644 --- a/Backend/src/controllers/AuthController.ts +++ b/Backend/src/controllers/AuthController.ts @@ -33,32 +33,6 @@ export class AuthController { data: { token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - user: { - id: 1, - username: 'admin', - email: 'admin@elearning.local', - email_verified_at: new Date('2024-01-01T00:00:00Z'), - updated_at: new Date('2024-01-01T00:00:00Z'), - created_at: new Date('2024-01-01T00:00:00Z'), - role: { - code: 'ADMIN', - name: { - th: 'āļœāļđāđ‰āļ”āļđāđāļĨāļĢāļ°āļšāļš', - en: 'Administrator' - } - }, - profile: { - prefix: { - th: 'āļ™āļēāļĒ', - en: 'Mr.' - }, - first_name: 'Admin', - last_name: 'User', - phone: null, - avatar_url: null, - birth_date: null - } - } } }) public async login(@Body() body: LoginRequest): Promise { diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 81fd8b86..fd4b0d20 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -27,13 +27,11 @@ export class CategoriesAdminController { @SuccessResponse('200', 'Category created successfully') @Response('401', 'Invalid or expired token') public async createCategory(@Request() request: any, @Body() body: createCategory): Promise { - const token = request.headers.authorization?.replace('Bearer ', '') || ''; - // Validate body const { error } = CreateCategoryValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); - return await this.categoryService.createCategory(token, body); + return await this.categoryService.createCategory(request.user.id, body); } @Put('{id}') @@ -41,13 +39,11 @@ export class CategoriesAdminController { @SuccessResponse('200', 'Category updated successfully') @Response('401', 'Invalid or expired token') public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise { - const token = request.headers.authorization?.replace('Bearer ', '') || ''; - // Validate body const { error } = UpdateCategoryValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); - return await this.categoryService.updateCategory(token, body.id, body); + return await this.categoryService.updateCategory(request.user.id, body.id, body); } @Delete('{id}') @@ -55,7 +51,6 @@ export class CategoriesAdminController { @SuccessResponse('200', 'Category deleted successfully') @Response('401', 'Invalid or expired token') public async deleteCategory(@Request() request: any, @Path() id: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', '') || ''; - return await this.categoryService.deleteCategory(token, id); + return await this.categoryService.deleteCategory(request.user.id, id); } } \ No newline at end of file diff --git a/Backend/src/controllers/CertificateController.ts b/Backend/src/controllers/CertificateController.ts index 13ec2075..a6e3f5bf 100644 --- a/Backend/src/controllers/CertificateController.ts +++ b/Backend/src/controllers/CertificateController.ts @@ -1,5 +1,4 @@ import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa'; -import { ValidationError } from '../middleware/errorHandler'; import { CertificateService } from '../services/certificate.service'; import { GenerateCertificateResponse, @@ -21,9 +20,7 @@ export class CertificateController { @SuccessResponse('200', 'Certificates retrieved successfully') @Response('401', 'Invalid or expired token') public async listMyCertificates(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await this.certificateService.listMyCertificates({ token }); + return await this.certificateService.listMyCertificates({ userId: request.user.id }); } /** @@ -37,9 +34,7 @@ export class CertificateController { @Response('401', 'Invalid or expired token') @Response('404', 'Certificate not found') public async getCertificate(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await this.certificateService.getCertificate({ token, course_id: courseId }); + return await this.certificateService.getCertificate({ userId: request.user.id, course_id: courseId }); } /** @@ -54,8 +49,6 @@ export class CertificateController { @Response('401', 'Invalid or expired token') @Response('404', 'Enrollment not found') public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await this.certificateService.generateCertificate({ token, course_id: courseId }); + return await this.certificateService.generateCertificate({ userId: request.user.id, course_id: courseId }); } } diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index 7ba48f5c..213ae095 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -65,14 +65,11 @@ export class ChaptersLessonInstructorController { @Path() courseId: number, @Body() body: CreateChapterBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = CreateChapterValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.createChapter({ - token, + userId: request.user.id, course_id: courseId, title: body.title, description: body.description, @@ -96,14 +93,11 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Body() body: UpdateChapterBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = UpdateChapterValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.updateChapter({ - token, + userId: request.user.id, course_id: courseId, chapter_id: chapterId, ...body, @@ -125,9 +119,7 @@ export class ChaptersLessonInstructorController { @Path() courseId: number, @Path() chapterId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await chaptersLessonService.deleteChapter({ token, course_id: courseId, chapter_id: chapterId }); + return await chaptersLessonService.deleteChapter({ userId: request.user.id, course_id: courseId, chapter_id: chapterId }); } /** @@ -143,14 +135,11 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Body() body: ReorderChapterBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = ReorderChapterValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.reorderChapter({ - token, + userId: request.user.id, course_id: courseId, chapter_id: chapterId, sort_order: body.sort_order, @@ -174,9 +163,7 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId }); + return await chaptersLessonService.getLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId }); } /** @@ -192,14 +179,11 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Body() body: CreateLessonBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = CreateLessonValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.createLesson({ - token, + userId: request.user.id, course_id: courseId, chapter_id: chapterId, title: body.title, @@ -223,14 +207,11 @@ export class ChaptersLessonInstructorController { @Path() lessonId: number, @Body() body: UpdateLessonBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = UpdateLessonValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.updateLesson({ - token, + userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId, @@ -258,9 +239,7 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await chaptersLessonService.deleteLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId }); + return await chaptersLessonService.deleteLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId }); } /** @@ -276,14 +255,11 @@ export class ChaptersLessonInstructorController { @Path() chapterId: number, @Body() body: ReorderLessonsBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = ReorderLessonsValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.reorderLessons({ - token, + userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: body.lesson_id, @@ -309,14 +285,11 @@ export class ChaptersLessonInstructorController { @Path() lessonId: number, @Body() body: AddQuestionBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = AddQuestionValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.addQuestion({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, ...body, @@ -338,14 +311,11 @@ export class ChaptersLessonInstructorController { @Path() questionId: number, @Body() body: UpdateQuestionBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = UpdateQuestionValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.updateQuestion({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, question_id: questionId, @@ -364,14 +334,11 @@ export class ChaptersLessonInstructorController { @Path() questionId: number, @Body() body: ReorderQuestionBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = ReorderQuestionValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.reorderQuestion({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, question_id: questionId, @@ -393,10 +360,8 @@ export class ChaptersLessonInstructorController { @Path() lessonId: number, @Path() questionId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await chaptersLessonService.deleteQuestion({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, question_id: questionId, @@ -417,14 +382,11 @@ export class ChaptersLessonInstructorController { @Path() lessonId: number, @Body() body: UpdateQuizBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = UpdateQuizValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.updateQuiz({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, ...body, diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 3657e928..4556281c 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -22,12 +22,10 @@ import { GetCourseApprovalHistoryResponse, setCourseDraftResponse, CloneCourseResponse, + GetAllMyStudentsResponse, } from '../types/CoursesInstructor.types'; import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; -import jwt from 'jsonwebtoken'; -import { config } from '../config'; - @Route('api/instructors/courses') @Tags('CoursesInstructor') export class CoursesInstructorController { @@ -45,11 +43,7 @@ export class CoursesInstructorController { @Request() request: any, @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await CoursesInstructorService.listMyCourses({ token, status }); + return await CoursesInstructorService.listMyCourses({ userId: request.user.id, status }); } /** @@ -67,9 +61,23 @@ export class CoursesInstructorController { @Path() courseId: number, @Query() query: string ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await CoursesInstructorService.searchInstructors({ token, query, course_id: courseId }); + return await CoursesInstructorService.searchInstructors({ userId: request.user.id, query, course_id: courseId }); + } + + /** + * āļ”āļķāļ‡āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”āļ‚āļ­āļ‡ instructor āļ—āļļāļāļ„āļ­āļĢāđŒāļŠ + * Get all students enrolled in all of instructor's courses + * + * @returns āļĢāļēāļĒāļāļēāļĢāļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āđāļĒāļāļ•āļēāļĄāļ„āļ­āļĢāđŒāļŠ āļžāļĢāđ‰āļ­āļĄ total_enrolled āđāļĨāļ° total_completed āļ•āđˆāļ­āļ„āļ­āļĢāđŒāļŠ + */ + @Get('my-students') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Students retrieved successfully') + @Response('401', 'Unauthorized') + public async getMyAllStudents( + @Request() request: any + ): Promise { + return await CoursesInstructorService.getMyAllStudents(request.user.id); } /** @@ -83,11 +91,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Course not found') public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await CoursesInstructorService.getmyCourse({ token, course_id: courseId }); + return await CoursesInstructorService.getmyCourse({ userId: request.user.id, course_id: courseId }); } /** @@ -101,13 +105,10 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Course not found') public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = UpdateCourseValidator.validate(body.data); if (error) throw new ValidationError(error.details[0].message); - return await CoursesInstructorService.updateCourse(token, courseId, body.data); + return await CoursesInstructorService.updateCourse(request.user.id, courseId, body.data); } /** @@ -126,10 +127,6 @@ export class CoursesInstructorController { @FormField() data: string, @UploadedFile() thumbnail?: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const parsed = JSON.parse(data); const { error, value } = CreateCourseValidator.validate(parsed); if (error) throw new ValidationError(error.details[0].message); @@ -137,7 +134,7 @@ export class CoursesInstructorController { // Validate thumbnail file type if provided if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail'); - return await CoursesInstructorService.createCourse(value, decoded.id, thumbnail); + return await CoursesInstructorService.createCourse(value, request.user.id, thumbnail); } /** @@ -156,11 +153,9 @@ export class CoursesInstructorController { @Path() courseId: number, @UploadedFile() file: Express.Multer.File ): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); - return await CoursesInstructorService.uploadThumbnail(token, courseId, file); + return await CoursesInstructorService.uploadThumbnail(request.user.id, courseId, file); } /** @@ -174,9 +169,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Course not found') public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.deleteCourse(token, courseId); + return await CoursesInstructorService.deleteCourse(request.user.id, courseId); } /** @@ -196,14 +189,11 @@ export class CoursesInstructorController { @Path() courseId: number, @Body() body: { title: { th: string; en: string } } ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - const { error } = CloneCourseValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); const result = await CoursesInstructorService.cloneCourse({ - token, + userId: request.user.id, course_id: courseId, title: body.title }); @@ -220,9 +210,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Course not found') public async submitCourse(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId }); + return await CoursesInstructorService.sendCourseForReview({ userId: request.user.id, course_id: courseId }); } /** @@ -236,9 +224,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Course not found') public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.setCourseDraft({ token, course_id: courseId }); + return await CoursesInstructorService.setCourseDraft({ userId: request.user.id, course_id: courseId }); } /** @@ -253,9 +239,7 @@ export class CoursesInstructorController { @Response('403', 'You are not an instructor of this course') @Response('404', 'Course not found') public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.getCourseApprovals(token, courseId); + return await CoursesInstructorService.getCourseApprovals(request.user.id, courseId); } /** @@ -269,9 +253,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Instructors not found') public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId }); + return await CoursesInstructorService.listInstructorsOfCourse({ userId: request.user.id, course_id: courseId }); } /** @@ -286,9 +268,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Instructor not found') public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, email_or_username: emailOrUsername }); + return await CoursesInstructorService.addInstructorToCourse({ userId: request.user.id, course_id: courseId, email_or_username: emailOrUsername }); } /** @@ -303,9 +283,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Instructor not found') public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId }); + return await CoursesInstructorService.removeInstructorFromCourse({ userId: request.user.id, course_id: courseId, user_id: userId }); } /** @@ -320,9 +298,7 @@ export class CoursesInstructorController { @Response('401', 'Invalid or expired token') @Response('404', 'Primary instructor not found') public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided') - return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId }); + return await CoursesInstructorService.setPrimaryInstructor({ userId: request.user.id, course_id: courseId, user_id: userId }); } /** @@ -347,10 +323,8 @@ export class CoursesInstructorController { @Query() search?: string, @Query() status?: 'ENROLLED' | 'COMPLETED' ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await CoursesInstructorService.getEnrolledStudents({ - token, + userId: request.user.id, course_id: courseId, page, limit, @@ -376,10 +350,8 @@ export class CoursesInstructorController { @Path() courseId: number, @Path() studentId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await CoursesInstructorService.getEnrolledStudentDetail({ - token, + userId: request.user.id, course_id: courseId, student_id: studentId, }); @@ -410,10 +382,8 @@ export class CoursesInstructorController { @Query() search?: string, @Query() isPassed?: boolean ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await CoursesInstructorService.getQuizScores({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, page, @@ -442,10 +412,8 @@ export class CoursesInstructorController { @Path() lessonId: number, @Path() studentId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await CoursesInstructorService.getQuizAttemptDetail({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, student_id: studentId, @@ -467,8 +435,6 @@ export class CoursesInstructorController { @Request() request: any, @Path() courseId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await CoursesInstructorService.getCourseApprovalHistory(token, courseId); + return await CoursesInstructorService.getCourseApprovalHistory(request.user.id, courseId); } } \ No newline at end of file diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index 87a5a613..62e01636 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -36,11 +36,7 @@ export class CoursesStudentController { @Response('404', 'Course not found') @Response('409', 'Already enrolled in this course') public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.enrollCourse({ token, course_id: courseId }); + return await this.service.enrollCourse({ userId: request.user.id, course_id: courseId }); } /** @@ -60,11 +56,7 @@ export class CoursesStudentController { @Query() limit?: number, @Query() status?: EnrollmentStatus ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.GetEnrolledCourses({ token, page, limit, status }); + return await this.service.GetEnrolledCourses({ userId: request.user.id, page, limit, status }); } /** @@ -79,11 +71,7 @@ export class CoursesStudentController { @Response('403', 'Not enrolled in this course') @Response('404', 'Course not found') public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.getCourseLearning({ token, course_id: courseId }); + return await this.service.getCourseLearning({ userId: request.user.id, course_id: courseId }); } /** @@ -103,11 +91,7 @@ export class CoursesStudentController { @Path() courseId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId }); + return await this.service.getlessonContent({ userId: request.user.id, course_id: courseId, lesson_id: lessonId }); } /** @@ -126,11 +110,7 @@ export class CoursesStudentController { @Path() courseId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId }); + return await this.service.checkAccessLesson({ userId: request.user.id, course_id: courseId, lesson_id: lessonId }); } /** @@ -149,14 +129,12 @@ export class CoursesStudentController { @Path() lessonId: number, @Body() body: SaveVideoProgressBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); const { error } = SaveVideoProgressValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await this.service.saveVideoProgress({ - token, + userId: request.user.id, lesson_id: lessonId, video_progress_seconds: body.video_progress_seconds, video_duration_seconds: body.video_duration_seconds, @@ -178,11 +156,7 @@ export class CoursesStudentController { @Request() request: any, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.getVideoProgress({ token, lesson_id: lessonId }); + return await this.service.getVideoProgress({ userId: request.user.id, lesson_id: lessonId }); } /** @@ -202,11 +176,7 @@ export class CoursesStudentController { @Path() courseId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.service.completeLesson({ token, lesson_id: lessonId }); + return await this.service.completeLesson({ userId: request.user.id, lesson_id: lessonId }); } /** @@ -227,14 +197,12 @@ export class CoursesStudentController { @Path() lessonId: number, @Body() body: SubmitQuizBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); const { error } = SubmitQuizValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await this.service.submitQuiz({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, answers: body.answers, @@ -258,12 +226,8 @@ export class CoursesStudentController { @Path() courseId: number, @Path() lessonId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } return await this.service.getQuizAttempts({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, }); diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index 0323f4ab..33cc1cef 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -42,8 +42,6 @@ export class LessonsController { @Path() lessonId: number, @UploadedFile() video: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); if (!video) { throw new ValidationError('Video file is required'); @@ -57,7 +55,7 @@ export class LessonsController { }; return await chaptersLessonService.uploadVideo({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, video: videoInfo, @@ -87,8 +85,6 @@ export class LessonsController { @Path() lessonId: number, @UploadedFile() video: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); if (!video) { throw new ValidationError('Video file is required'); @@ -102,7 +98,7 @@ export class LessonsController { }; return await chaptersLessonService.updateVideo({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, video: videoInfo, @@ -132,8 +128,6 @@ export class LessonsController { @Path() lessonId: number, @UploadedFile() attachment: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); if (!attachment) { throw new ValidationError('Attachment file is required'); @@ -147,7 +141,7 @@ export class LessonsController { }; return await chaptersLessonService.uploadAttachment({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, attachment: attachmentInfo, @@ -177,11 +171,9 @@ export class LessonsController { @Path() lessonId: number, @Path() attachmentId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await chaptersLessonService.deleteAttachment({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, attachment_id: attachmentId, @@ -211,14 +203,12 @@ export class LessonsController { @Path() lessonId: number, @Body() body: SetYouTubeVideoBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); const { error } = SetYouTubeVideoValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.setYouTubeVideo({ - token, + userId: request.user.id, course_id: courseId, lesson_id: lessonId, youtube_video_id: body.youtube_video_id, diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 720bff7c..fa0b17c5 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -1,5 +1,4 @@ import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; -import { ValidationError } from '../middleware/errorHandler'; import { RecommendedCoursesService } from '../services/RecommendedCourses.service'; import { ListApprovedCoursesResponse, @@ -25,9 +24,7 @@ export class RecommendedCoursesController { @Query() search?: string, @Query() categoryId?: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); + return await RecommendedCoursesService.listApprovedCourses(request.user.id, { search, categoryId }); } /** @@ -43,9 +40,7 @@ export class RecommendedCoursesController { @Response('403', 'Forbidden - Admin only') @Response('404', 'Course not found') public async getCourseById(@Request() request: any, @Path() courseId: number): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.getCourseById(token, courseId); + return await RecommendedCoursesService.getCourseById(request.user.id, courseId); } /** @@ -65,8 +60,6 @@ export class RecommendedCoursesController { @Path() courseId: number, @Query() is_recommended: boolean ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended); + return await RecommendedCoursesService.toggleRecommended(request.user.id, courseId, is_recommended); } } diff --git a/Backend/src/controllers/UserController.ts b/Backend/src/controllers/UserController.ts index ccbe7c76..72b9a33a 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -1,12 +1,10 @@ -import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put, UploadedFile } from 'tsoa'; +import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Request, Put, UploadedFile } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { UserService } from '../services/user.service'; import { UserResponse, - ProfileResponse, ProfileUpdate, ProfileUpdateResponse, - ChangePasswordRequest, ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, @@ -23,8 +21,6 @@ export class UserController { /** * Get current user profile - * @summary Retrieve authenticated user's profile information - * @param request Express request object with JWT token in Authorization header */ @Get('me') @SuccessResponse('200', 'User found') @@ -32,12 +28,7 @@ export class UserController { @Response('401', 'Invalid or expired token') @Security('jwt') public async getMe(@Request() request: any): Promise { - // Extract token from Authorization header - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.userService.getUserProfile(token); + return await this.userService.getUserProfile(request.user.id); } @Put('me') @@ -47,34 +38,20 @@ export class UserController { @Response('400', 'Validation error') public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise { const { error } = profileUpdateSchema.validate(body); - if (error) { - throw new ValidationError(error.details[0].message); - } - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.userService.updateProfile(token, body); + if (error) throw new ValidationError(error.details[0].message); + return await this.userService.updateProfile(request.user.id, body); } @Get('roles') @Security('jwt') @SuccessResponse('200', 'Roles retrieved successfully') @Response('401', 'Invalid or expired token') - public async getRoles(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.userService.getRoles(token); + public async getRoles(): Promise { + return await this.userService.getRoles(); } /** * Change password - * @summary Change user password using old password - * @param request Express request object with JWT token in Authorization header - * @param body Old password and new password - * @returns Success message */ @Post('change-password') @Security('jwt') @@ -83,22 +60,12 @@ export class UserController { @Response('400', 'Validation error') public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise { const { error } = changePasswordSchema.validate(body); - if (error) { - throw new ValidationError(error.details[0].message); - } - - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - - return await this.userService.changePassword(token, body.oldPassword, body.newPassword); + if (error) throw new ValidationError(error.details[0].message); + return await this.userService.changePassword(request.user.id, body.oldPassword, body.newPassword); } /** * Upload user avatar picture - * @param request Express request object with JWT token in Authorization header - * @param file Avatar image file */ @Post('upload-avatar') @Security('jwt') @@ -109,9 +76,6 @@ export class UserController { @Request() request: any, @UploadedFile() file: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - // Validate file type (images only) if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); @@ -119,13 +83,11 @@ export class UserController { const maxSize = 5 * 1024 * 1024; // 5MB if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB'); - return await this.userService.uploadAvatarPicture(token, file); + return await this.userService.uploadAvatarPicture(request.user.id, file); } /** * Send verification email to user - * @summary Send email verification link to authenticated user's email - * @param request Express request object with JWT token in Authorization header */ @Post('send-verify-email') @Security('jwt') @@ -133,9 +95,7 @@ export class UserController { @Response('401', 'Invalid or expired token') @Response('400', 'Email already verified') public async sendVerifyEmail(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await this.userService.sendVerifyEmail(token); + return await this.userService.sendVerifyEmail(request.user.id); } /** diff --git a/Backend/src/controllers/announcementsController.ts b/Backend/src/controllers/announcementsController.ts index 8ac03c70..aa38ca54 100644 --- a/Backend/src/controllers/announcementsController.ts +++ b/Backend/src/controllers/announcementsController.ts @@ -37,10 +37,8 @@ export class AnnouncementsController { @Query() page?: number, @Query() limit?: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await announcementsService.listAnnouncement({ - token, + userId: request.user.id, course_id: courseId, page, limit, @@ -63,9 +61,6 @@ export class AnnouncementsController { @FormField() data: string, @UploadedFiles() files?: Express.Multer.File[] ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - // Parse JSON data field const parsed = JSON.parse(data) as CreateAnnouncementBody; @@ -74,7 +69,7 @@ export class AnnouncementsController { if (error) throw new ValidationError(error.details[0].message); return await announcementsService.createAnnouncement({ - token, + userId: request.user.id, course_id: courseId, title: parsed.title, content: parsed.content, @@ -103,15 +98,12 @@ export class AnnouncementsController { @Path() announcementId: number, @Body() body: UpdateAnnouncementBody ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - // Validate body const { error } = UpdateAnnouncementValidator.validate(body); if (error) throw new ValidationError(error.details[0].message); return await announcementsService.updateAnnouncement({ - token, + userId: request.user.id, course_id: courseId, announcement_id: announcementId, title: body.title, @@ -139,10 +131,8 @@ export class AnnouncementsController { @Path() courseId: number, @Path() announcementId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await announcementsService.deleteAnnouncement({ - token, + userId: request.user.id, course_id: courseId, announcement_id: announcementId, }); @@ -166,10 +156,8 @@ export class AnnouncementsController { @Path() announcementId: number, @UploadedFile() file: Express.Multer.File ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await announcementsService.uploadAttachment({ - token, + userId: request.user.id, course_id: courseId, announcement_id: announcementId, file: file as any, @@ -195,10 +183,8 @@ export class AnnouncementsController { @Path() announcementId: number, @Path() attachmentId: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await announcementsService.deleteAttachment({ - token, + userId: request.user.id, course_id: courseId, announcement_id: announcementId, attachment_id: attachmentId, @@ -228,10 +214,8 @@ export class AnnouncementsStudentController { @Query() page?: number, @Query() limit?: number ): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); return await announcementsService.listAnnouncement({ - token, + userId: request.user.id, course_id: courseId, page, limit, diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index 0596034c..808f2747 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -1,8 +1,6 @@ import { prisma } from '../config/database'; -import { config } from '../config'; import { logger } from '../config/logger'; -import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; -import jwt from 'jsonwebtoken'; +import { ValidationError, NotFoundError } from '../middleware/errorHandler'; import { getPresignedUrl } from '../config/minio'; import { ListPendingCoursesResponse, @@ -18,7 +16,7 @@ export class AdminCourseApprovalService { /** * Get all pending courses for admin review */ - static async listPendingCourses(token: string): Promise { + static async listPendingCourses(userId: number): Promise { try { const courses = await prisma.course.findMany({ where: { status: 'PENDING' }, @@ -96,9 +94,8 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to list pending courses', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: 0, @@ -113,7 +110,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(token: string, courseId: number): Promise { + static async getCourseDetail(userId: number, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -228,9 +225,8 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to get course detail', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -245,9 +241,8 @@ export class AdminCourseApprovalService { /** * Approve a course */ - static async approveCourse(token: string, courseId: number, comment?: string): Promise { + static async approveCourse(userId: number, courseId: number, comment?: string): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) { @@ -264,7 +259,7 @@ export class AdminCourseApprovalService { where: { id: courseId }, data: { status: 'APPROVED', - approved_by: decoded.id, + approved_by: userId, approved_at: new Date() } }), @@ -273,7 +268,7 @@ export class AdminCourseApprovalService { data: { course_id: courseId, submitted_by: course.created_by, - reviewed_by: decoded.id, + reviewed_by: userId, action: 'APPROVED', previous_status: course.status, new_status: 'APPROVED', @@ -284,7 +279,7 @@ export class AdminCourseApprovalService { // Audit log - APPROVE_COURSE await auditService.logSync({ - userId: decoded.id, + userId, action: AuditAction.APPROVE_COURSE, entityType: 'Course', entityId: courseId, @@ -299,9 +294,8 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to approve course', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -317,9 +311,8 @@ export class AdminCourseApprovalService { /** * Reject a course */ - static async rejectCourse(token: string, courseId: number, comment: string): Promise { + static async rejectCourse(userId: number, courseId: number, comment: string): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) { @@ -350,7 +343,7 @@ export class AdminCourseApprovalService { data: { course_id: courseId, submitted_by: course.created_by, - reviewed_by: decoded.id, + reviewed_by: userId, action: 'REJECTED', previous_status: course.status, new_status: 'REJECTED', @@ -361,7 +354,7 @@ export class AdminCourseApprovalService { // Audit log - REJECT_COURSE await auditService.logSync({ - userId: decoded.id, + userId, action: AuditAction.REJECT_COURSE, entityType: 'Course', entityId: courseId, @@ -376,9 +369,8 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to reject course', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index 003670b1..63a68356 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -59,14 +59,11 @@ import { AuditAction } from '@prisma/client'; * āļ•āļĢāļ§āļˆāļŠāļ­āļšāļŠāļīāļ—āļ˜āļīāđŒāđ€āļ‚āđ‰āļēāļ–āļķāļ‡ Course (āļŠāļģāļŦāļĢāļąāļšāļ—āļąāđ‰āļ‡ Instructor āđāļĨāļ° Student) * Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number } */ -async function validateCourseAccess(token: string, course_id: number): Promise<{ +async function validateCourseAccess(userId: number, course_id: number): Promise<{ hasAccess: boolean; role: 'INSTRUCTOR' | 'STUDENT' | null; userId: number; }> { - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; - const userId = decodedToken.id; - const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); @@ -98,9 +95,8 @@ async function validateCourseAccess(token: string, course_id: number): Promise<{ export class ChaptersLessonService { async listChapters(request: ChaptersRequest): Promise { try { - const { token, course_id } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const { userId, course_id } = request; + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } @@ -117,14 +113,13 @@ export class ChaptersLessonService { async createChapter(request: CreateChapterInput): Promise { try { - const { token, course_id, title, description, sort_order } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, title, description, sort_order } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to create chapter'); } @@ -132,7 +127,7 @@ export class ChaptersLessonService { // Audit log - CREATE Chapter auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.CREATE, entityType: 'Chapter', entityId: chapter.id, @@ -142,9 +137,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error creating chapter: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: 0, @@ -159,14 +153,13 @@ export class ChaptersLessonService { async updateChapter(request: UpdateChapterInput): Promise { try { - const { token, course_id, chapter_id, title, description, sort_order } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, chapter_id, title, description, sort_order } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to update chapter'); } @@ -174,9 +167,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error updating chapter: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, @@ -191,14 +183,13 @@ export class ChaptersLessonService { async deleteChapter(request: DeleteChapterRequest): Promise { try { - const { token, course_id, chapter_id } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, chapter_id } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to delete chapter'); } @@ -206,7 +197,7 @@ export class ChaptersLessonService { // Audit log - DELETE Chapter auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.DELETE, entityType: 'Chapter', entityId: chapter_id, @@ -219,9 +210,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter deleted successfully' }; } catch (error) { logger.error(`Error deleting chapter: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, @@ -236,14 +226,13 @@ export class ChaptersLessonService { async reorderChapter(request: ReorderChapterRequest): Promise { try { - const { token, course_id, chapter_id, sort_order: newSortOrder } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, chapter_id, sort_order: newSortOrder } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to reorder chapter'); } @@ -313,9 +302,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; } catch (error) { logger.error(`Error reordering chapter: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Chapter', entityId: request.chapter_id, @@ -335,14 +323,13 @@ export class ChaptersLessonService { */ async createLesson(request: CreateLessonInput): Promise { try { - const { token, course_id, chapter_id, title, content, type, sort_order } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, chapter_id, title, content, type, sort_order } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to create lesson'); } @@ -354,7 +341,6 @@ export class ChaptersLessonService { // If QUIZ type, create empty Quiz shell if (type === 'QUIZ') { - const userId = decodedToken.id; await prisma.quiz.create({ data: { @@ -376,7 +362,7 @@ export class ChaptersLessonService { // Audit log - CREATE Lesson (QUIZ) auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.CREATE, entityType: 'Lesson', entityId: lesson.id, @@ -388,7 +374,7 @@ export class ChaptersLessonService { // Audit log - CREATE Lesson auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.CREATE, entityType: 'Lesson', entityId: lesson.id, @@ -398,9 +384,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error creating lesson: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: 0, @@ -419,10 +404,10 @@ export class ChaptersLessonService { */ async getLesson(request: GetLessonRequest): Promise { try { - const { token, course_id, lesson_id } = request; + const { userId, course_id, lesson_id } = request; // Check access for both instructor and enrolled student - const access = await validateCourseAccess(token, course_id); + const access = await validateCourseAccess(userId, course_id); if (!access.hasAccess) { throw new ForbiddenError('You do not have access to this course'); } @@ -549,9 +534,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; } catch (error) { logger.error(`Error fetching lesson: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -566,14 +550,13 @@ export class ChaptersLessonService { async updateLesson(request: UpdateLessonRequest): Promise { try { - const { token, course_id, lesson_id, data } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, data } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedError('Invalid token'); } - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not permitted to update lesson'); } @@ -581,9 +564,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error updating lesson: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -602,14 +584,13 @@ export class ChaptersLessonService { */ async reorderLessons(request: ReorderLessonsRequest): Promise { try { - const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons'); // Verify chapter exists and belongs to the course @@ -682,9 +663,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; } catch (error) { logger.error(`Error reordering lessons: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -704,14 +684,13 @@ export class ChaptersLessonService { */ async deleteLesson(request: DeleteLessonRequest): Promise { try { - const { token, course_id, lesson_id } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson'); // Fetch lesson with all related data @@ -751,7 +730,7 @@ export class ChaptersLessonService { // Audit log - DELETE Lesson auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.DELETE, entityType: 'Lesson', entityId: lesson_id, @@ -764,9 +743,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson deleted successfully' }; } catch (error) { logger.error(`Error deleting lesson: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -789,14 +767,13 @@ export class ChaptersLessonService { */ async uploadVideo(request: UploadVideoInput): Promise { try { - const { token, course_id, lesson_id, video } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, video } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is VIDEO type @@ -833,7 +810,7 @@ export class ChaptersLessonService { // Audit log - UPLOAD_FILE (Video) auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.UPLOAD_FILE, entityType: 'Lesson', entityId: lesson_id, @@ -853,9 +830,8 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading video: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -874,14 +850,13 @@ export class ChaptersLessonService { */ async updateVideo(request: UpdateVideoInput): Promise { try { - const { token, course_id, lesson_id, video } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, video } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is VIDEO type @@ -946,9 +921,8 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error updating video: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -967,14 +941,13 @@ export class ChaptersLessonService { */ async setYouTubeVideo(request: SetYouTubeVideoInput): Promise { try { - const { token, course_id, lesson_id, youtube_video_id, video_title } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, youtube_video_id, video_title } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is VIDEO type @@ -1038,9 +1011,8 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error setting YouTube video: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Lesson', entityId: request.lesson_id, @@ -1059,14 +1031,13 @@ export class ChaptersLessonService { */ async uploadAttachment(request: UploadAttachmentInput): Promise { try { - const { token, course_id, lesson_id, attachment } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, attachment } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists @@ -1101,7 +1072,7 @@ export class ChaptersLessonService { // Audit log - UPLOAD_FILE (Attachment) auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.UPLOAD_FILE, entityType: 'LessonAttachment', entityId: newAttachment.id, @@ -1125,9 +1096,8 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'LessonAttachment', entityId: request.lesson_id, @@ -1146,14 +1116,13 @@ export class ChaptersLessonService { */ async deleteAttachment(request: DeleteAttachmentInput): Promise { try { - const { token, course_id, lesson_id, attachment_id } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, attachment_id } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists @@ -1184,7 +1153,7 @@ export class ChaptersLessonService { // Audit log - DELETE_FILE (Attachment) auditService.log({ - userId: decodedToken.id, + userId: userId, action: AuditAction.DELETE_FILE, entityType: 'LessonAttachment', entityId: attachment_id, @@ -1194,9 +1163,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Attachment deleted successfully' }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'LessonAttachment', entityId: request.attachment_id, @@ -1216,14 +1184,13 @@ export class ChaptersLessonService { */ async addQuestion(request: AddQuestionInput): Promise { try { - const { token, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is QUIZ type @@ -1281,9 +1248,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error adding question: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: 0, @@ -1303,14 +1269,13 @@ export class ChaptersLessonService { */ async updateQuestion(request: UpdateQuestionInput): Promise { try { - const { token, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is QUIZ type @@ -1367,9 +1332,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error updating question: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, @@ -1384,14 +1348,13 @@ export class ChaptersLessonService { async reorderQuestion(request: ReorderQuestionInput): Promise { try { - const { token, course_id, lesson_id, question_id, sort_order } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, question_id, sort_order } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is QUIZ type @@ -1471,9 +1434,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; } catch (error) { logger.error(`Error reordering question: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, @@ -1493,14 +1455,13 @@ export class ChaptersLessonService { */ async deleteQuestion(request: DeleteQuestionInput): Promise { try { - const { token, course_id, lesson_id, question_id } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, question_id } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is QUIZ type @@ -1530,9 +1491,8 @@ export class ChaptersLessonService { return { code: 200, message: 'Question deleted successfully' }; } catch (error) { logger.error(`Error deleting question: ${error}`); - const decodedToken = jwt.decode(request.token) as { id: number } | null; await auditService.logSync({ - userId: decodedToken?.id || 0, + userId: request.userId || 0, action: AuditAction.ERROR, entityType: 'Question', entityId: request.question_id, @@ -1680,14 +1640,13 @@ export class ChaptersLessonService { */ async updateQuiz(request: UpdateQuizInput): Promise { try { - const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request; - const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request; await CoursesInstructorService.validateCourseStatus(course_id); - const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('Invalid token'); - const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); + const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); // Verify lesson exists and is QUIZ type diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index e1b40d0c..959ef80f 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -1,9 +1,7 @@ import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; -import { config } from '../config'; import { logger } from '../config/logger'; -import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; -import jwt from 'jsonwebtoken'; +import { ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { CreateCourseInput, @@ -27,6 +25,7 @@ import { SearchInstructorResponse, GetEnrolledStudentsInput, GetEnrolledStudentsResponse, + EnrolledStudentData, GetQuizScoresInput, GetQuizScoresResponse, GetQuizAttemptDetailInput, @@ -38,6 +37,7 @@ import { CloneCourseResponse, setCourseDraft, setCourseDraftResponse, + GetAllMyStudentsResponse, } from "../types/CoursesInstructor.types"; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -121,10 +121,9 @@ export class CoursesInstructorService { static async listMyCourses(input: ListMyCoursesInput): Promise { try { - const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { - user_id: decoded.id, + user_id: input.userId, course: input.status ? { status: input.status } : undefined }, include: { @@ -157,9 +156,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve courses', { error }); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: 0, @@ -174,12 +172,10 @@ export class CoursesInstructorService { static async getmyCourse(getmyCourse: getmyCourse): Promise { try { - const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string }; - // Check if user is instructor of this course const courseInstructor = await prisma.courseInstructor.findFirst({ where: { - user_id: decoded.id, + user_id: getmyCourse.userId, course_id: getmyCourse.course_id }, include: { @@ -225,9 +221,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course', { error }); - const decoded = jwt.decode(getmyCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: getmyCourse.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: getmyCourse.course_id, @@ -240,9 +235,9 @@ export class CoursesInstructorService { } } - static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise { + static async updateCourse(userId: number, courseId: number, courseData: UpdateCourseInput): Promise { try { - await this.validateCourseInstructor(token, courseId); + await this.validateCourseInstructor(userId, courseId); const course = await prisma.course.update({ where: { @@ -258,9 +253,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to update course', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -273,9 +267,9 @@ export class CoursesInstructorService { } } - static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { + static async uploadThumbnail(userId: number, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { try { - await this.validateCourseInstructor(token, courseId); + await this.validateCourseInstructor(userId, courseId); // Get current course to check for existing thumbnail const currentCourse = await prisma.course.findUnique({ @@ -322,9 +316,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to upload thumbnail', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -337,9 +330,9 @@ export class CoursesInstructorService { } } - static async deleteCourse(token: string, courseId: number): Promise { + static async deleteCourse(userId: number, courseId: number): Promise { try { - const courseInstructorId = await this.validateCourseInstructor(token, courseId); + const courseInstructorId = await this.validateCourseInstructor(userId, courseId); if (!courseInstructorId.is_primary) { throw new ForbiddenError('You have no permission to delete this course'); } @@ -365,9 +358,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to delete course', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -382,11 +374,10 @@ export class CoursesInstructorService { static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise { try { - const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseApproval.create({ data: { course_id: sendCourseForReview.course_id, - submitted_by: decoded.id, + submitted_by: sendCourseForReview.userId, } }); await prisma.course.update({ @@ -398,7 +389,7 @@ export class CoursesInstructorService { } }); await auditService.logSync({ - userId: decoded.id, + userId: sendCourseForReview.userId, action: AuditAction.UPDATE, entityType: 'Course', entityId: sendCourseForReview.course_id, @@ -412,9 +403,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to send course for review', { error }); - const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: sendCourseForReview.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: sendCourseForReview.course_id, @@ -429,7 +419,7 @@ export class CoursesInstructorService { static async setCourseDraft(setCourseDraft: setCourseDraft): Promise { try { - await this.validateCourseInstructor(setCourseDraft.token, setCourseDraft.course_id); + await this.validateCourseInstructor(setCourseDraft.userId, setCourseDraft.course_id); await prisma.course.update({ where: { id: setCourseDraft.course_id, @@ -445,9 +435,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to set course to draft', { error }); - const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: setCourseDraft.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: setCourseDraft.course_id, @@ -460,7 +449,7 @@ export class CoursesInstructorService { } } - static async getCourseApprovals(token: string, courseId: number): Promise<{ + static async getCourseApprovals(userId: number, courseId: number): Promise<{ code: number; message: string; data: any[]; @@ -468,7 +457,7 @@ export class CoursesInstructorService { }> { try { // Validate instructor access - await this.validateCourseInstructor(token, courseId); + await this.validateCourseInstructor(userId, courseId); const approvals = await prisma.courseApproval.findMany({ where: { course_id: courseId }, @@ -491,9 +480,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course approvals', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -510,8 +498,6 @@ export class CoursesInstructorService { static async searchInstructors(input: SearchInstructorInput): Promise { try { - const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number }; - // Get existing instructors in the course const existingInstructors = await prisma.courseInstructor.findMany({ where: { course_id: input.course_id }, @@ -528,7 +514,7 @@ export class CoursesInstructorService { ], role: { code: 'INSTRUCTOR' }, id: { - notIn: [decoded.id, ...existingInstructorIds], + notIn: [input.userId, ...existingInstructorIds], }, }, include: { @@ -563,9 +549,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to search instructors', { error }); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -581,7 +566,7 @@ export class CoursesInstructorService { static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise { try { // Validate user is instructor of this course - await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id); + await this.validateCourseInstructor(addinstructorCourse.userId, addinstructorCourse.course_id); // Find user by email or username const user = await prisma.user.findFirst({ @@ -619,9 +604,8 @@ export class CoursesInstructorService { } }); - const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: addinstructorCourse.userId, action: AuditAction.CREATE, entityType: 'Course', entityId: addinstructorCourse.course_id, @@ -637,9 +621,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to add instructor to course', { error }); - const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: addinstructorCourse.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: addinstructorCourse.course_id, @@ -654,7 +637,6 @@ export class CoursesInstructorService { static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise { try { - const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseInstructor.delete({ where: { course_id_user_id: { @@ -665,7 +647,7 @@ export class CoursesInstructorService { }); await auditService.logSync({ - userId: decoded?.id || 0, + userId: removeinstructorCourse.userId, action: AuditAction.DELETE, entityType: 'Course', entityId: removeinstructorCourse.course_id, @@ -682,9 +664,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to remove instructor from course', { error }); - const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: removeinstructorCourse.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: removeinstructorCourse.course_id, @@ -699,7 +680,6 @@ export class CoursesInstructorService { static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise { try { - const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { course_id: listinstructorCourse.course_id, @@ -743,9 +723,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve instructors of course', { error }); - const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: listinstructorCourse.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: listinstructorCourse.course_id, @@ -760,7 +739,6 @@ export class CoursesInstructorService { static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise { try { - const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string }; await prisma.courseInstructor.update({ where: { course_id_user_id: { @@ -774,7 +752,7 @@ export class CoursesInstructorService { }); await auditService.logSync({ - userId: decoded?.id || 0, + userId: setprimaryCourseInstructor.userId, action: AuditAction.UPDATE, entityType: 'Course', entityId: setprimaryCourseInstructor.course_id, @@ -791,9 +769,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to set primary instructor', { error }); - const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: setprimaryCourseInstructor.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: setprimaryCourseInstructor.course_id, @@ -806,11 +783,10 @@ export class CoursesInstructorService { } } - static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + static async validateCourseInstructor(userId: number, courseId: number): Promise<{ user_id: number; is_primary: boolean }> { const courseInstructor = await prisma.courseInstructor.findFirst({ where: { - user_id: decoded.id, + user_id: userId, course_id: courseId } }); @@ -839,10 +815,10 @@ export class CoursesInstructorService { */ static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise { try { - const { token, course_id, page = 1, limit = 20, search, status } = input; + const { userId, course_id, page = 1, limit = 20, search, status } = input; // Validate instructor - await this.validateCourseInstructor(token, course_id); + await this.validateCourseInstructor(userId, course_id); // Build where clause const whereClause: any = { course_id }; @@ -917,9 +893,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled students: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -938,11 +913,10 @@ export class CoursesInstructorService { */ static async getQuizScores(input: GetQuizScoresInput): Promise { try { - const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input; // Validate instructor - await this.validateCourseInstructor(token, course_id); + await this.validateCourseInstructor(userId, course_id); // Get lesson and verify it's a QUIZ type const lesson = await prisma.lesson.findUnique({ @@ -1095,9 +1069,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz scores: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1116,10 +1089,10 @@ export class CoursesInstructorService { */ static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise { try { - const { token, course_id, lesson_id, student_id } = input; + const { userId, course_id, lesson_id, student_id } = input; // Validate instructor - await this.validateCourseInstructor(token, course_id); + await this.validateCourseInstructor(userId, course_id); // Get lesson and verify it's a QUIZ type const lesson = await prisma.lesson.findUnique({ @@ -1219,9 +1192,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz attempt detail: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1240,10 +1212,10 @@ export class CoursesInstructorService { */ static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise { try { - const { token, course_id, student_id } = input; + const { userId, course_id, student_id } = input; // Validate instructor - await this.validateCourseInstructor(token, course_id); + await this.validateCourseInstructor(userId, course_id); // Get student info const student = await prisma.user.findUnique({ @@ -1367,9 +1339,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled student detail: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1386,12 +1357,10 @@ export class CoursesInstructorService { * āļ”āļķāļ‡āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļīāļ„āļ­āļĢāđŒāļŠ * Get course approval history for instructor to see rejection reasons */ - static async getCourseApprovalHistory(token: string, courseId: number): Promise { + static async getCourseApprovalHistory(userId: number, courseId: number): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; - // Validate instructor access - await this.validateCourseInstructor(token, courseId); + await this.validateCourseInstructor(userId, courseId); // Get course with approval history const course = await prisma.course.findUnique({ @@ -1434,9 +1403,8 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting course approval history: ${error}`); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || undefined, + userId, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -1454,11 +1422,10 @@ export class CoursesInstructorService { */ static async cloneCourse(input: CloneCourseInput): Promise { try { - const { token, course_id, title } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, title } = input; // Validate instructor - const courseInstructor = await this.validateCourseInstructor(token, course_id); + const courseInstructor = await this.validateCourseInstructor(userId, course_id); if (!courseInstructor) { throw new ForbiddenError('You are not an instructor of this course'); } @@ -1508,7 +1475,7 @@ export class CoursesInstructorService { is_free: originalCourse.is_free, have_certificate: originalCourse.have_certificate, status: 'DRAFT', // Reset status - created_by: decoded.id + created_by: userId } }); @@ -1516,7 +1483,7 @@ export class CoursesInstructorService { await tx.courseInstructor.create({ data: { course_id: createdCourse.id, - user_id: decoded.id, + user_id: userId, is_primary: true } }); @@ -1589,7 +1556,7 @@ export class CoursesInstructorService { shuffle_questions: lesson.quiz.shuffle_questions, shuffle_choices: lesson.quiz.shuffle_choices, show_answers_after_completion: lesson.quiz.show_answers_after_completion, - created_by: decoded.id + created_by: userId } }); @@ -1636,7 +1603,7 @@ export class CoursesInstructorService { }); await auditService.logSync({ - userId: decoded.id, + userId: input.userId, action: AuditAction.CREATE, entityType: 'Course', entityId: newCourse.id, @@ -1658,9 +1625,8 @@ export class CoursesInstructorService { } catch (error) { logger.error(`Error cloning course: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1672,4 +1638,45 @@ export class CoursesInstructorService { throw error; } } + + /** + * āļ”āļķāļ‡āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”āđƒāļ™āļ—āļļāļāļ„āļ­āļĢāđŒāļŠāļ‚āļ­āļ‡ instructor + * Get all enrolled students across all courses the instructor owns/teaches + */ + static async getMyAllStudents(userId: number): Promise { + try { + // āļŦāļē course IDs āļ—āļąāđ‰āļ‡āļŦāļĄāļ”āļ—āļĩāđˆ instructor āļŠāļ­āļ™ + const instructorCourses = await prisma.courseInstructor.findMany({ + where: { user_id: userId }, + select: { course_id: true } + }); + + const courseIds = instructorCourses.map(ci => ci.course_id); + + if (courseIds.length === 0) { + return { code: 200, message: 'Students retrieved successfully', total_students: 0, total_completed: 0 }; + } + + // unique students āļ—āļąāđ‰āļ‡āļŦāļĄāļ” + const uniqueStudents = await prisma.enrollment.groupBy({ + by: ['user_id'], + where: { course_id: { in: courseIds } }, + }); + + // āļˆāļģāļ™āļ§āļ™ enrollment āļ—āļĩāđˆ COMPLETED + const totalCompleted = await prisma.enrollment.count({ + where: { course_id: { in: courseIds }, status: 'COMPLETED' } + }); + + return { + code: 200, + message: 'Students retrieved successfully', + total_students: uniqueStudents.length, + total_completed: totalCompleted, + }; + } catch (error) { + logger.error(`Error getting all students: ${error}`); + throw error; + } + } } diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 986695b1..2ae599fc 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -133,7 +133,7 @@ export class CoursesStudentService { async enrollCourse(input: EnrollCourseInput): Promise { try { const { course_id } = input; - const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; + const userId = input.userId; const course = await prisma.course.findUnique({ where: { id: course_id }, @@ -146,7 +146,7 @@ export class CoursesStudentService { const existingEnrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -159,7 +159,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.create({ data: { course_id, - user_id: decoded.id, + user_id: userId, status: 'ENROLLED', enrolled_at: new Date(), }, @@ -167,11 +167,11 @@ export class CoursesStudentService { // Audit log - ENROLL auditService.log({ - userId: decoded.id, + userId: userId, action: AuditAction.ENROLL, entityType: 'Enrollment', entityId: enrollment.id, - newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' } + newValue: { course_id, user_id: userId, status: 'ENROLLED' } }); return { @@ -187,9 +187,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(`Error enrolling in course: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -206,13 +206,13 @@ export class CoursesStudentService { async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise { try { - const { token } = input; + // destructure input const page = input.page ?? 1; const limit = input.limit ?? 20; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const userId = input.userId; const enrollments = await prisma.enrollment.findMany({ where: { - user_id: decoded.id, + user_id: userId, }, include: { course: { @@ -230,7 +230,7 @@ export class CoursesStudentService { }); const total = await prisma.enrollment.count({ where: { - user_id: decoded.id, + user_id: userId, }, }); @@ -274,9 +274,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -290,8 +290,8 @@ export class CoursesStudentService { } async getCourseLearning(input: GetCourseLearningInput): Promise { try { - const { token, course_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { course_id } = input; + const userId = input.userId; // Get course with chapters and lessons (basic info only) const course = await prisma.course.findUnique({ @@ -330,7 +330,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -345,7 +345,7 @@ export class CoursesStudentService { prisma.enrollment.update({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -357,7 +357,7 @@ export class CoursesStudentService { const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ where: { - user_id: decoded.id, + user_id: userId, lesson_id: { in: lessonIds }, }, }); @@ -453,9 +453,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -470,8 +470,8 @@ export class CoursesStudentService { async getlessonContent(input: GetLessonContentInput): Promise { try { - const { token, course_id, lesson_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { course_id, lesson_id } = input; + const userId = input.userId; // Import MinIO functions @@ -479,7 +479,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -528,7 +528,7 @@ export class CoursesStudentService { const lessonProgress = await prisma.lessonProgress.findUnique({ where: { user_id_lesson_id: { - user_id: decoded.id, + user_id: userId, lesson_id, }, }, @@ -639,7 +639,7 @@ export class CoursesStudentService { // Get latest quiz attempt for this user latestQuizAttempt = await prisma.quizAttempt.findFirst({ where: { - user_id: decoded.id, + user_id: userId, quiz_id: lesson.quiz.id, }, orderBy: { @@ -726,9 +726,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -744,14 +744,14 @@ export class CoursesStudentService { async checkAccessLesson(input: CheckLessonAccessInput): Promise { try { - const { token, course_id, lesson_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { course_id, lesson_id } = input; + const userId = input.userId; // Check enrollment const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -845,7 +845,7 @@ export class CoursesStudentService { // Get user's progress for prerequisite lessons const prerequisiteProgress = await prisma.lessonProgress.findMany({ where: { - user_id: decoded.id, + user_id: userId, lesson_id: { in: prerequisiteIds }, }, }); @@ -879,7 +879,7 @@ export class CoursesStudentService { // Check if user passed the quiz const quizAttempt = await prisma.quizAttempt.findFirst({ where: { - user_id: decoded.id, + user_id: userId, quiz_id: prereqLesson.quiz.id, is_passed: true, }, @@ -925,9 +925,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -942,8 +942,8 @@ export class CoursesStudentService { async getVideoProgress(input: GetVideoProgressInput): Promise { try { - const { token, lesson_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { lesson_id } = input; + const userId = input.userId; // Get lesson to find course_id const lesson = await prisma.lesson.findUnique({ @@ -966,7 +966,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -980,7 +980,7 @@ export class CoursesStudentService { const progress = await prisma.lessonProgress.findUnique({ where: { user_id_lesson_id: { - user_id: decoded.id, + user_id: userId, lesson_id, }, }, @@ -1010,9 +1010,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -1027,8 +1027,8 @@ export class CoursesStudentService { async saveVideoProgress(input: SaveVideoProgressInput): Promise { try { - const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { lesson_id, video_progress_seconds, video_duration_seconds } = input; + const userId = input.userId; // Get lesson to find course_id const lesson = await prisma.lesson.findUnique({ @@ -1051,7 +1051,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -1074,12 +1074,12 @@ export class CoursesStudentService { const progress = await prisma.lessonProgress.upsert({ where: { user_id_lesson_id: { - user_id: decoded.id, + user_id: userId, lesson_id, }, }, create: { - user_id: decoded.id, + user_id: userId, lesson_id, video_progress_seconds, video_duration_seconds: video_duration_seconds ?? null, @@ -1098,7 +1098,7 @@ export class CoursesStudentService { // If video completed, mark lesson as complete and update enrollment progress let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; if (isCompleted) { - const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); + const result = await this.markLessonComplete(userId, lesson_id, course_id); enrollmentProgress = result.enrollmentProgress; } @@ -1118,9 +1118,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Enrollment', entityId: 0, @@ -1135,8 +1135,8 @@ export class CoursesStudentService { async completeLesson(input: CompleteLessonInput): Promise { try { - const { token, lesson_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { lesson_id } = input; + const userId = input.userId; // Get lesson with chapter and course info const lesson = await prisma.lesson.findUnique({ @@ -1185,7 +1185,7 @@ export class CoursesStudentService { const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -1196,7 +1196,7 @@ export class CoursesStudentService { } // Mark lesson as complete and update enrollment progress - const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id); + const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(userId, lesson_id, course_id); const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress; // Find next lesson @@ -1225,7 +1225,7 @@ export class CoursesStudentService { // Check if certificate already exists const existingCertificate = await prisma.certificate.findFirst({ where: { - user_id: decoded.id, + user_id: userId, course_id, }, }); @@ -1233,10 +1233,10 @@ export class CoursesStudentService { if (!existingCertificate) { await prisma.certificate.create({ data: { - user_id: decoded.id, + user_id: userId, course_id, enrollment_id: enrollment.id, - file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`, + file_path: `certificates/${course_id}/${userId}/${Date.now()}.pdf`, issued_at: new Date(), }, }); @@ -1261,9 +1261,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(`Error completing lesson: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'LessonProgress', entityId: input.lesson_id, @@ -1283,14 +1283,14 @@ export class CoursesStudentService { */ async submitQuiz(input: SubmitQuizInput): Promise { try { - const { token, course_id, lesson_id, answers } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { course_id, lesson_id, answers } = input; + const userId = input.userId; // Check enrollment const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -1331,7 +1331,7 @@ export class CoursesStudentService { // Get previous attempt count const previousAttempts = await prisma.quizAttempt.count({ where: { - user_id: decoded.id, + user_id: userId, quiz_id: quiz.id, }, }); @@ -1384,7 +1384,7 @@ export class CoursesStudentService { const now = new Date(); const quizAttempt = await prisma.quizAttempt.create({ data: { - user_id: decoded.id, + user_id: userId, quiz_id: quiz.id, score: earnedScore, total_questions: quiz.questions.length, @@ -1400,7 +1400,7 @@ export class CoursesStudentService { // If passed, mark lesson as complete and update enrollment progress let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; if (isPassed) { - const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); + const result = await this.markLessonComplete(userId, lesson_id, course_id); enrollmentProgress = result.enrollmentProgress; } @@ -1429,9 +1429,9 @@ export class CoursesStudentService { }; } catch (error) { logger.error(`Error submitting quiz: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; + // userId from middleware await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'QuizAttempt', entityId: 0, @@ -1452,14 +1452,14 @@ export class CoursesStudentService { */ async getQuizAttempts(input: GetQuizAttemptsInput): Promise { try { - const { token, course_id, lesson_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { course_id, lesson_id } = input; + const userId = input.userId; // Check enrollment const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -1494,7 +1494,7 @@ export class CoursesStudentService { // Get all quiz attempts for this user const attempts = await prisma.quizAttempt.findMany({ where: { - user_id: decoded.id, + user_id: userId, quiz_id: lesson.quiz.id, }, orderBy: { attempt_number: 'desc' }, @@ -1539,22 +1539,20 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); - const decoded = jwt.decode(input.token) as { id: number } | null; - if (decoded?.id) { - await auditService.logSync({ - userId: decoded.id, - action: AuditAction.ERROR, - entityType: 'QuizAttempt', - entityId: 0, - metadata: { - operation: 'get_quiz_attempts', - course_id: input.course_id, - lesson_id: input.lesson_id, - error: error instanceof Error ? error.message : String(error) - } - }); - } + // userId from middleware + await auditService.logSync({ + userId: input.userId, + action: AuditAction.ERROR, + entityType: 'QuizAttempt', + entityId: 0, + metadata: { + operation: 'get_quiz_attempts', + course_id: input.course_id, + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } -} \ No newline at end of file +} diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index 22440eb2..750131c6 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -1,8 +1,6 @@ import { prisma } from '../config/database'; -import { config } from '../config'; import { logger } from '../config/logger'; import { NotFoundError, ValidationError } from '../middleware/errorHandler'; -import jwt from 'jsonwebtoken'; import { getPresignedUrl } from '../config/minio'; import { ListApprovedCoursesResponse, @@ -20,7 +18,7 @@ export class RecommendedCoursesService { * List all approved courses (for admin to manage recommendations) */ static async listApprovedCourses( - token: string, + userId: number, filters?: { search?: string; categoryId?: number } ): Promise { try { @@ -108,19 +106,16 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to list approved courses', { error }); - const decoded = jwt.decode(token) as { id: number } | null; - if (decoded?.id) { - await auditService.logSync({ - userId: decoded.id, - action: AuditAction.ERROR, - entityType: 'RecommendedCourses', - entityId: 0, - metadata: { - operation: 'list_approved_courses', - error: error instanceof Error ? error.message : String(error) - } - }); - } + await auditService.logSync({ + userId, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'list_approved_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -128,7 +123,7 @@ export class RecommendedCoursesService { /** * Get course by ID (for admin to view details) */ - static async getCourseById(token: string, courseId: number): Promise { + static async getCourseById(userId: number, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -213,19 +208,16 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to get course by ID', { error }); - const decoded = jwt.decode(token) as { id: number } | null; - if (decoded?.id) { - await auditService.logSync({ - userId: decoded.id, - action: AuditAction.ERROR, - entityType: 'RecommendedCourses', - entityId: 0, - metadata: { - operation: 'get_course_by_id', - error: error instanceof Error ? error.message : String(error) - } - }); - } + await auditService.logSync({ + userId, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'get_course_by_id', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -234,12 +226,11 @@ export class RecommendedCoursesService { * Toggle course recommendation status */ static async toggleRecommended( - token: string, + userId: number, courseId: number, isRecommended: boolean ): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) { @@ -257,7 +248,7 @@ export class RecommendedCoursesService { // Audit log await auditService.logSync({ - userId: decoded.id, + userId, action: AuditAction.UPDATE, entityType: 'Course', entityId: courseId, @@ -276,9 +267,8 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to toggle recommended status', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'RecommendedCourses', entityId: courseId, diff --git a/Backend/src/services/announcements.service.ts b/Backend/src/services/announcements.service.ts index 7e8b2d3e..36adabca 100644 --- a/Backend/src/services/announcements.service.ts +++ b/Backend/src/services/announcements.service.ts @@ -1,8 +1,6 @@ import { prisma } from '../config/database'; -import { config } from '../config'; import { logger } from '../config/logger'; -import { UnauthorizedError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; -import jwt from 'jsonwebtoken'; +import { ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ListAnnouncementResponse, CreateAnnouncementInput, @@ -31,27 +29,26 @@ export class AnnouncementsService { */ async listAnnouncement(input: ListAnnouncementInput): Promise { try { - const { token, course_id, page = 1, limit = 10 } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const { userId, course_id, page = 1, limit = 10 } = input; // Check user access - instructor, admin, or enrolled student const user = await prisma.user.findUnique({ - where: { id: decoded.id }, + where: { id: userId }, include: { role: true }, }); - if (!user) throw new UnauthorizedError('Invalid token'); + if (!user) throw new ForbiddenError('User not found'); // Admin can access all courses const isAdmin = user.role.code === 'ADMIN'; // Check if instructor of this course const isInstructor = await prisma.courseInstructor.findFirst({ - where: { course_id, user_id: decoded.id }, + where: { course_id, user_id: userId }, }); // Check if enrolled student const isEnrolled = await prisma.enrollment.findFirst({ - where: { course_id, user_id: decoded.id }, + where: { course_id, user_id: userId }, }); if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements'); @@ -61,7 +58,7 @@ export class AnnouncementsService { // Students only see PUBLISHED announcements with published_at <= now const now = new Date(); const whereClause: any = { course_id }; - + if (!(isAdmin || isInstructor)) { // Students: only show PUBLISHED and published_at <= now whereClause.status = 'PUBLISHED'; @@ -130,9 +127,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error listing announcements: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, @@ -150,11 +146,10 @@ export class AnnouncementsService { */ async createAnnouncement(input: CreateAnnouncementInput): Promise { try { - const { token, course_id, title, content, status, is_pinned, published_at, files } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, title, content, status, is_pinned, published_at, files } = input; // Validate instructor access - await CoursesInstructorService.validateCourseInstructor(token, course_id); + await CoursesInstructorService.validateCourseInstructor(userId, course_id); // Determine published_at: use provided value or default to now if status is PUBLISHED let finalPublishedAt: Date | null = null; @@ -171,7 +166,7 @@ export class AnnouncementsService { status: status as any, is_pinned, published_at: finalPublishedAt, - created_by: decoded.id, + created_by: userId, }, }); @@ -236,9 +231,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error creating announcement: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, @@ -256,11 +250,10 @@ export class AnnouncementsService { */ async updateAnnouncement(input: UpdateAnnouncementInput): Promise { try { - const { token, course_id, announcement_id, title, content, status, is_pinned, published_at } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, announcement_id, title, content, status, is_pinned, published_at } = input; // Validate instructor access - await CoursesInstructorService.validateCourseInstructor(token, course_id); + await CoursesInstructorService.validateCourseInstructor(userId, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ @@ -289,7 +282,7 @@ export class AnnouncementsService { status: status as any, is_pinned, published_at: finalPublishedAt, - updated_by: decoded.id, + updated_by: userId, }, include: { attachments: true, @@ -320,9 +313,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error updating announcement: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, @@ -340,11 +332,10 @@ export class AnnouncementsService { */ async deleteAnnouncement(input: DeleteAnnouncementInput): Promise { try { - const { token, course_id, announcement_id } = input; - jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, announcement_id } = input; // Validate instructor access - await CoursesInstructorService.validateCourseInstructor(token, course_id); + await CoursesInstructorService.validateCourseInstructor(userId, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ @@ -376,9 +367,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting announcement: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, @@ -396,11 +386,10 @@ export class AnnouncementsService { */ async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise { try { - const { token, course_id, announcement_id, file } = input; - jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, announcement_id, file } = input; // Validate instructor access - await CoursesInstructorService.validateCourseInstructor(token, course_id); + await CoursesInstructorService.validateCourseInstructor(userId, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ @@ -451,9 +440,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, @@ -471,11 +459,10 @@ export class AnnouncementsService { */ async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise { try { - const { token, course_id, announcement_id, attachment_id } = input; - jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id, announcement_id, attachment_id } = input; // Validate instructor access - await CoursesInstructorService.validateCourseInstructor(token, course_id); + await CoursesInstructorService.validateCourseInstructor(userId, course_id); // Check attachment exists and belongs to announcement in this course const attachment = await prisma.announcementAttachment.findFirst({ @@ -508,9 +495,8 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Announcement', entityId: 0, diff --git a/Backend/src/services/auth.service.ts b/Backend/src/services/auth.service.ts index 66899bed..9d16d594 100644 --- a/Backend/src/services/auth.service.ts +++ b/Backend/src/services/auth.service.ts @@ -74,7 +74,6 @@ export class AuthService { data: { token, refreshToken, - user: await this.formatUserResponse(user) } }; } diff --git a/Backend/src/services/categories.service.ts b/Backend/src/services/categories.service.ts index 0e0defa2..e18ec617 100644 --- a/Backend/src/services/categories.service.ts +++ b/Backend/src/services/categories.service.ts @@ -1,10 +1,7 @@ import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; -import { config } from '../config'; import { logger } from '../config/logger'; -import jwt from 'jsonwebtoken'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; -import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -26,14 +23,13 @@ export class CategoryService { } } - async createCategory(token: string, category: createCategory): Promise { + async createCategory(userId: number, category: createCategory): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const newCategory = await prisma.category.create({ data: category }); auditService.log({ - userId: decoded.id, + userId, action: AuditAction.CREATE, entityType: 'Category', entityId: newCategory.id, @@ -47,13 +43,13 @@ export class CategoryService { name: newCategory.name as { th: string; en: string }, slug: newCategory.slug, description: newCategory.description as { th: string; en: string }, - created_by: decoded.id, + created_by: userId, } }; } catch (error) { logger.error('Failed to create category', { error }); await auditService.logSync({ - userId: 0, + userId, action: AuditAction.ERROR, entityType: 'Category', entityId: 0, @@ -66,15 +62,14 @@ export class CategoryService { } } - async updateCategory(token: string, id: number, category: updateCategory): Promise { + async updateCategory(userId: number, id: number, category: updateCategory): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const updatedCategory = await prisma.category.update({ where: { id }, data: category }); auditService.log({ - userId: decoded.id, + userId, action: AuditAction.UPDATE, entityType: 'Category', entityId: id, @@ -88,13 +83,13 @@ export class CategoryService { name: updatedCategory.name as { th: string; en: string }, slug: updatedCategory.slug, description: updatedCategory.description as { th: string; en: string }, - updated_by: decoded.id, + updated_by: userId, } }; } catch (error) { logger.error('Failed to update category', { error }); await auditService.logSync({ - userId: 0, + userId, action: AuditAction.ERROR, entityType: 'Category', entityId: 0, @@ -107,14 +102,13 @@ export class CategoryService { } } - async deleteCategory(token: string, id: number): Promise { + async deleteCategory(userId: number, id: number): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const deletedCategory = await prisma.category.delete({ where: { id } }); auditService.log({ - userId: decoded.id, + userId, action: AuditAction.DELETE, entityType: 'Category', entityId: id, @@ -127,7 +121,7 @@ export class CategoryService { } catch (error) { logger.error('Failed to delete category', { error }); await auditService.logSync({ - userId: 0, + userId, action: AuditAction.ERROR, entityType: 'Category', entityId: 0, diff --git a/Backend/src/services/certificate.service.ts b/Backend/src/services/certificate.service.ts index 4041ec41..eafaffa0 100644 --- a/Backend/src/services/certificate.service.ts +++ b/Backend/src/services/certificate.service.ts @@ -1,8 +1,6 @@ import { prisma } from '../config/database'; -import { config } from '../config'; import { logger } from '../config/logger'; import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler'; -import jwt from 'jsonwebtoken'; import { PDFDocument, rgb } from 'pdf-lib'; import fontkit from '@pdf-lib/fontkit'; import * as fs from 'fs'; @@ -29,14 +27,13 @@ export class CertificateService { */ async generateCertificate(input: GenerateCertificateInput): Promise { try { - const { token, course_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id } = input; // Check enrollment and completion const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { - user_id: decoded.id, + user_id: userId, course_id, }, }, @@ -65,7 +62,7 @@ export class CertificateService { // Check if certificate already exists const existingCertificate = await prisma.certificate.findFirst({ where: { - user_id: decoded.id, + user_id: userId, course_id, }, }); @@ -103,13 +100,13 @@ export class CertificateService { // Upload to MinIO const timestamp = Date.now(); - const filePath = `certificates/${course_id}/${decoded.id}/${timestamp}.pdf`; + const filePath = `certificates/${course_id}/${userId}/${timestamp}.pdf`; await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf'); // Save to database const certificate = await prisma.certificate.create({ data: { - user_id: decoded.id, + user_id: userId, course_id, enrollment_id: enrollment.id, file_path: filePath, @@ -118,7 +115,7 @@ export class CertificateService { }); auditService.log({ - userId: decoded.id, + userId, action: AuditAction.CREATE, entityType: 'Certificate', entityId: certificate.id, @@ -139,9 +136,8 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to generate certificate', { error }); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Certificate', entityId: 0, @@ -160,12 +156,11 @@ export class CertificateService { */ async getCertificate(input: GetCertificateInput): Promise { try { - const { token, course_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId, course_id } = input; const certificate = await prisma.certificate.findFirst({ where: { - user_id: decoded.id, + user_id: userId, course_id, }, include: { @@ -202,9 +197,8 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to get certificate', { error }); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Certificate', entityId: 0, @@ -223,12 +217,11 @@ export class CertificateService { */ async listMyCertificates(input: ListMyCertificatesInput): Promise { try { - const { token } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + const { userId } = input; const certificates = await prisma.certificate.findMany({ where: { - user_id: decoded.id, + user_id: userId, }, include: { enrollment: { @@ -267,9 +260,8 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to list certificates', { error }); - const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id, + userId: input.userId, action: AuditAction.ERROR, entityType: 'Certificate', entityId: 0, diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index 69153d51..552c7665 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -24,15 +24,10 @@ import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; export class UserService { - async getUserProfile(token: string): Promise { + async getUserProfile(userId: number): Promise { try { - // Decode JWT token to get user ID - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; - const user = await prisma.user.findUnique({ - where: { - id: decoded.id - }, + where: { id: userId }, include: { profile: true, role: true @@ -68,14 +63,6 @@ export class UserService { } : undefined }; } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } logger.error('Error fetching user profile:', error); throw error; } @@ -84,12 +71,9 @@ export class UserService { /** * Change user password */ - async changePassword(token: string, oldPassword: string, newPassword: string): Promise { + async changePassword(userId: number, oldPassword: string, newPassword: string): Promise { try { - // Decode JWT token to get user ID - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; - - const user = await prisma.user.findUnique({ where: { id: decoded.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('User not found'); // Check if account is deactivated @@ -127,21 +111,12 @@ export class UserService { message: 'Password changed successfully' }; } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } logger.error('Failed to change password', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'User', - entityId: decoded?.id || 0, + entityId: userId, metadata: { operation: 'change_password', error: error instanceof Error ? error.message : String(error) @@ -154,12 +129,9 @@ export class UserService { /** * Update user profile */ - async updateProfile(token: string, profile: ProfileUpdate): Promise { + async updateProfile(userId: number, profile: ProfileUpdate): Promise { try { - // Decode JWT token to get user ID - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; - - const user = await prisma.user.findUnique({ where: { id: decoded.id } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedError('User not found'); // Check if account is deactivated @@ -189,21 +161,12 @@ export class UserService { } }; } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } logger.error('Failed to update profile', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.UPDATE, entityType: 'UserProfile', - entityId: decoded?.id || 0, + entityId: userId, metadata: { operation: 'update_profile', error: error instanceof Error ? error.message : String(error) @@ -213,9 +176,8 @@ export class UserService { } } - async getRoles(token: string): Promise { + async getRoles(): Promise { try { - jwt.verify(token, config.jwt.secret); const roles = await prisma.role.findMany({ select: { id: true, @@ -224,14 +186,6 @@ export class UserService { }); return { roles }; } catch (error) { - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } logger.error('Failed to get roles', { error }); throw error; } @@ -240,13 +194,11 @@ export class UserService { /** * Upload avatar picture to MinIO */ - async uploadAvatarPicture(token: string, file: Express.Multer.File): Promise { + async uploadAvatarPicture(userId: number, file: Express.Multer.File): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; - // Check if user exists const user = await prisma.user.findUnique({ - where: { id: decoded.id }, + where: { id: userId }, include: { profile: true } }); @@ -265,7 +217,7 @@ export class UserService { const fileName = file.originalname || 'avatar'; const extension = fileName.split('.').pop() || 'jpg'; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; - const filePath = `avatars/${decoded.id}/${safeFilename}`; + const filePath = `avatars/${userId}/${safeFilename}`; // Delete old avatar if exists if (user.profile?.avatar_url) { @@ -285,13 +237,13 @@ export class UserService { // Update or create profile - store only file path if (user.profile) { await prisma.userProfile.update({ - where: { user_id: decoded.id }, + where: { user_id: userId }, data: { avatar_url: filePath } }); } else { await prisma.userProfile.create({ data: { - user_id: decoded.id, + user_id: userId, avatar_url: filePath, first_name: '', last_name: '' @@ -301,10 +253,10 @@ export class UserService { // Audit log - UPLOAD_AVATAR await auditService.logSync({ - userId: decoded.id, + userId, action: AuditAction.UPLOAD_FILE, entityType: 'User', - entityId: decoded.id, + entityId: userId, metadata: { operation: 'upload_avatar', filePath @@ -318,26 +270,17 @@ export class UserService { code: 200, message: 'Avatar uploaded successfully', data: { - id: decoded.id, + id: userId, avatar_url: presignedUrl } }; } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } logger.error('Failed to upload avatar', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.UPLOAD_FILE, entityType: 'UserProfile', - entityId: decoded?.id || 0, + entityId: userId, metadata: { operation: 'upload_avatar', error: error instanceof Error ? error.message : String(error) @@ -390,12 +333,10 @@ export class UserService { /** * Send verification email to user */ - async sendVerifyEmail(token: string): Promise { + async sendVerifyEmail(userId: number): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string; roleCode: string }; - const user = await prisma.user.findUnique({ - where: { id: decoded.id }, + where: { id: userId }, include: { role: true } }); @@ -453,15 +394,12 @@ export class UserService { message: 'Verification email sent successfully' }; } catch (error) { - if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token'); - if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired'); logger.error('Failed to send verification email', { error }); - const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId, action: AuditAction.ERROR, entityType: 'UserProfile', - entityId: decoded?.id || 0, + entityId: userId, metadata: { operation: 'send_verification_email', error: error instanceof Error ? error.message : String(error) diff --git a/Backend/src/types/ChaptersLesson.typs.ts b/Backend/src/types/ChaptersLesson.typs.ts index 51a26e4b..a2dd9a37 100644 --- a/Backend/src/types/ChaptersLesson.typs.ts +++ b/Backend/src/types/ChaptersLesson.typs.ts @@ -98,18 +98,18 @@ export interface ChapterData { // ============================================ export interface ChaptersRequest { - token: string; + userId: number; course_id: number; } export interface GetChapterRequest { - token: string; + userId: number; course_id: number; chapter_id: number; } export interface CreateChapterInput { - token: string; + userId: number; course_id: number; title: MultiLanguageText; description?: MultiLanguageText; @@ -118,13 +118,13 @@ export interface CreateChapterInput { } export interface CreateChapterRequest { - token: string; + userId: number; course_id: number; data: CreateChapterInput; } export interface UpdateChapterInput { - token: string; + userId: number; course_id: number; chapter_id: number; title?: MultiLanguageText; @@ -134,20 +134,20 @@ export interface UpdateChapterInput { } export interface UpdateChapterRequest { - token: string; + userId: number; course_id: number; chapter_id: number; data: UpdateChapterInput; } export interface DeleteChapterRequest { - token: string; + userId: number; course_id: number; chapter_id: number; } export interface ReorderChapterRequest { - token: string; + userId: number; course_id: number; chapter_id: number; sort_order: number; @@ -199,7 +199,7 @@ export interface ReorderChapterResponse { // ============================================ export interface GetLessonRequest { - token: string; + userId: number; course_id: number; chapter_id: number; lesson_id: number; @@ -216,7 +216,7 @@ export interface UploadedFileInfo { } export interface CreateLessonInput { - token: string; + userId: number; course_id: number; chapter_id: number; title: MultiLanguageText; @@ -293,7 +293,7 @@ export interface QuizChoiceData { } export interface CreateLessonRequest { - token: string; + userId: number; course_id: number; chapter_id: number; data: CreateLessonInput; @@ -311,7 +311,7 @@ export interface UpdateLessonInput { } export interface UpdateLessonRequest { - token: string; + userId: number; course_id: number; chapter_id: number; lesson_id: number; @@ -319,14 +319,14 @@ export interface UpdateLessonRequest { } export interface DeleteLessonRequest { - token: string; + userId: number; course_id: number; chapter_id: number; lesson_id: number; } export interface ReorderLessonsRequest { - token: string; + userId: number; course_id: number; chapter_id: number; lesson_id: number; @@ -365,7 +365,7 @@ export interface UpdateLessonResponse { * Input for uploading video to a lesson */ export interface UploadVideoInput { - token: string; + userId: number; course_id: number; lesson_id: number; video: UploadedFileInfo; @@ -375,7 +375,7 @@ export interface UploadVideoInput { * Input for updating (replacing) video in a lesson */ export interface UpdateVideoInput { - token: string; + userId: number; course_id: number; lesson_id: number; video: UploadedFileInfo; @@ -385,7 +385,7 @@ export interface UpdateVideoInput { * Input for setting YouTube video to a lesson */ export interface SetYouTubeVideoInput { - token: string; + userId: number; course_id: number; lesson_id: number; youtube_video_id: string; @@ -411,7 +411,7 @@ export interface YouTubeVideoResponse { * Input for uploading a single attachment to a lesson */ export interface UploadAttachmentInput { - token: string; + userId: number; course_id: number; lesson_id: number; attachment: UploadedFileInfo; @@ -421,7 +421,7 @@ export interface UploadAttachmentInput { * Input for deleting an attachment from a lesson */ export interface DeleteAttachmentInput { - token: string; + userId: number; course_id: number; lesson_id: number; attachment_id: number; @@ -490,7 +490,7 @@ export interface LessonWithDetailsResponse { * Input for adding quiz to an existing QUIZ lesson */ export interface AddQuizToLessonInput { - token: string; + userId: number; course_id: number; lesson_id: number; quiz_data: { @@ -509,7 +509,7 @@ export interface AddQuizToLessonInput { * Input for adding a single question to a quiz lesson */ export interface AddQuestionInput { - token: string; + userId: number; course_id: number; lesson_id: number; question: MultiLanguageText; @@ -532,7 +532,7 @@ export interface AddQuestionResponse { * Input for updating a question */ export interface UpdateQuestionInput { - token: string; + userId: number; course_id: number; lesson_id: number; question_id: number; @@ -556,14 +556,14 @@ export interface UpdateQuestionResponse { * Input for deleting a question */ export interface DeleteQuestionInput { - token: string; + userId: number; course_id: number; lesson_id: number; question_id: number; } export interface ReorderQuestionInput { - token: string; + userId: number; course_id: number; lesson_id: number; question_id: number; @@ -588,7 +588,7 @@ export interface DeleteQuestionResponse { * Input for updating quiz settings */ export interface UpdateQuizInput { - token: string; + userId: number; course_id: number; lesson_id: number; title?: MultiLanguageText; diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index cc4aa149..8291a1d5 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -24,7 +24,7 @@ export interface createCourseResponse { } export interface ListMyCoursesInput { - token: string; + userId: number; status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; } @@ -42,7 +42,7 @@ export interface GetMyCourseResponse { } export interface getmyCourse { - token: string; + userId: number; course_id: number; } @@ -94,13 +94,13 @@ export interface listCourseinstructorResponse { } export interface addinstructorCourse { - token: string; + userId: number; email_or_username: string; course_id: number; } export interface SearchInstructorInput { - token: string; + userId: number; query: string; course_id: number; } @@ -145,12 +145,12 @@ export interface listinstructorCourseResponse { } export interface listinstructorCourse { - token: string; + userId: number; course_id: number; } export interface removeinstructorCourse { - token: string; + userId: number; user_id: number; course_id: number; } @@ -161,7 +161,7 @@ export interface removeinstructorCourseResponse { } export interface setprimaryCourseInstructor { - token: string; + userId: number; user_id: number; course_id: number; } @@ -172,12 +172,12 @@ export interface setprimaryCourseInstructorResponse { } export interface sendCourseForReview { - token: string; + userId: number; course_id: number; } export interface setCourseDraft { - token: string; + userId: number; course_id: number; } @@ -220,7 +220,7 @@ export interface GetCourseApprovalsResponse { // ============================================ export interface GetEnrolledStudentsInput { - token: string; + userId: number; course_id: number; page?: number; limit?: number; @@ -254,7 +254,7 @@ export interface GetEnrolledStudentsResponse { // ============================================ export interface GetQuizScoresInput { - token: string; + userId: number; course_id: number; lesson_id: number; page?: number; @@ -305,7 +305,7 @@ export interface GetQuizScoresResponse { // ============================================ export interface GetQuizAttemptDetailInput { - token: string; + userId: number; course_id: number; lesson_id: number; student_id: number; @@ -353,7 +353,7 @@ export interface GetQuizAttemptDetailResponse { // ============================================ export interface GetEnrolledStudentDetailInput { - token: string; + userId: number; course_id: number; student_id: number; } @@ -435,7 +435,7 @@ export interface GetCourseApprovalHistoryResponse { } export interface CloneCourseInput { - token: string; + userId: number; course_id: number; title: MultiLanguageText; } @@ -448,3 +448,14 @@ export interface CloneCourseResponse { title: MultiLanguageText; }; } + +// ============================================ +// Get All Students across all instructor courses +// ============================================ + +export interface GetAllMyStudentsResponse { + code: number; + message: string; + total_students: number; + total_completed: number; +} diff --git a/Backend/src/types/CoursesStudent.types.ts b/Backend/src/types/CoursesStudent.types.ts index 541787c1..2c92850f 100644 --- a/Backend/src/types/CoursesStudent.types.ts +++ b/Backend/src/types/CoursesStudent.types.ts @@ -9,7 +9,7 @@ export type MultiLangText = MultiLanguageText; // ============================================ export interface EnrollCourseInput { - token: string; + userId: number; course_id: number; } @@ -26,7 +26,7 @@ export interface EnrollCourseResponse { } export interface ListEnrolledCoursesInput { - token: string; + userId: number; page?: number; limit?: number; status?: EnrollmentStatus; @@ -64,7 +64,7 @@ export interface ListEnrolledCoursesResponse { // ============================================ export interface GetCourseLearningInput { - token: string; + userId: number; course_id: number; } @@ -126,7 +126,7 @@ export interface GetCourseLearningResponse { // ============================================ export interface GetLessonContentInput { - token: string; + userId: number; course_id: number; lesson_id: number; } @@ -204,7 +204,7 @@ export interface GetLessonContentResponse { // ============================================ export interface CheckLessonAccessInput { - token: string; + userId: number; course_id: number; lesson_id: number; } @@ -236,7 +236,7 @@ export interface CheckLessonAccessResponse { // ============================================ export interface SaveVideoProgressInput { - token: string; + userId: number; lesson_id: number; video_progress_seconds: number; video_duration_seconds?: number; @@ -258,7 +258,7 @@ export interface SaveVideoProgressResponse { } export interface GetVideoProgressInput { - token: string; + userId: number; lesson_id: number; } @@ -281,7 +281,7 @@ export interface GetVideoProgressResponse { // ============================================ export interface MarkLessonCompleteInput { - token: string; + userId: number; course_id: number; lesson_id: number; } @@ -314,7 +314,7 @@ export interface EnrollCourseBody { } export interface CompleteLessonInput { - token: string; + userId: number; lesson_id: number; } @@ -342,7 +342,7 @@ export interface QuizAnswerInput { } export interface SubmitQuizInput { - token: string; + userId: number; course_id: number; lesson_id: number; answers: QuizAnswerInput[]; @@ -384,7 +384,7 @@ export interface SubmitQuizResponse { // ============================================ export interface GetQuizAttemptsInput { - token: string; + userId: number; course_id: number; lesson_id: number; } diff --git a/Backend/src/types/announcements.types.ts b/Backend/src/types/announcements.types.ts index f960a961..b026809e 100644 --- a/Backend/src/types/announcements.types.ts +++ b/Backend/src/types/announcements.types.ts @@ -22,7 +22,7 @@ export interface AnnouncementAttachment { updated_at: Date; } -export interface ListAnnouncementResponse{ +export interface ListAnnouncementResponse { code: number; message: string; data: Announcement[]; @@ -31,15 +31,15 @@ export interface ListAnnouncementResponse{ limit: number; } -export interface ListAnnouncementInput{ - token: string; +export interface ListAnnouncementInput { + userId: number; course_id: number; page?: number; limit?: number; } -export interface CreateAnnouncementInput{ - token: string; +export interface CreateAnnouncementInput { + userId: number; course_id: number; title: MultiLanguageText; content: MultiLanguageText; @@ -49,39 +49,39 @@ export interface CreateAnnouncementInput{ files?: Express.Multer.File[]; } -export interface UploadAnnouncementAttachmentInput{ - token: string; +export interface UploadAnnouncementAttachmentInput { + userId: number; course_id: number; announcement_id: number; file: File; } -export interface UploadAnnouncementAttachmentResponse{ +export interface UploadAnnouncementAttachmentResponse { code: number; message: string; data: AnnouncementAttachment; } -export interface DeleteAnnouncementAttachmentInput{ - token: string; +export interface DeleteAnnouncementAttachmentInput { + userId: number; course_id: number; announcement_id: number; attachment_id: number; } -export interface DeleteAnnouncementAttachmentResponse{ +export interface DeleteAnnouncementAttachmentResponse { code: number; message: string; } -export interface CreateAnnouncementResponse{ +export interface CreateAnnouncementResponse { code: number; message: string; data: Announcement; } -export interface UpdateAnnouncementInput{ - token: string; +export interface UpdateAnnouncementInput { + userId: number; course_id: number; announcement_id: number; title: MultiLanguageText; @@ -92,19 +92,19 @@ export interface UpdateAnnouncementInput{ attachments?: AnnouncementAttachment[]; } -export interface UpdateAnnouncementResponse{ +export interface UpdateAnnouncementResponse { code: number; message: string; data: Announcement; } -export interface DeleteAnnouncementInput{ - token: string; +export interface DeleteAnnouncementInput { + userId: number; course_id: number; announcement_id: number; } -export interface DeleteAnnouncementResponse{ +export interface DeleteAnnouncementResponse { code: number; message: string; } diff --git a/Backend/src/types/auth.types.ts b/Backend/src/types/auth.types.ts index be1fe625..1ec9235a 100644 --- a/Backend/src/types/auth.types.ts +++ b/Backend/src/types/auth.types.ts @@ -28,7 +28,6 @@ export interface LoginResponse { data: { token: string; refreshToken: string; - user: UserResponse; }; } diff --git a/Backend/src/types/certificate.types.ts b/Backend/src/types/certificate.types.ts index a35caa19..ac442f76 100644 --- a/Backend/src/types/certificate.types.ts +++ b/Backend/src/types/certificate.types.ts @@ -3,7 +3,7 @@ // ============================================ export interface GenerateCertificateInput { - token: string; + userId: number; course_id: number; } @@ -19,7 +19,7 @@ export interface GenerateCertificateResponse { } export interface GetCertificateInput { - token: string; + userId: number; course_id: number; } @@ -37,7 +37,7 @@ export interface GetCertificateResponse { } export interface ListMyCertificatesInput { - token: string; + userId: number; } export interface ListMyCertificatesResponse { diff --git a/Backend/tests/tsconfig.json b/Backend/tests/tsconfig.json new file mode 100644 index 00000000..330cd8fd --- /dev/null +++ b/Backend/tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.test.json", + "compilerOptions": { + "rootDir": "..", + "types": [ + "node", + "jest" + ] + }, + "include": [ + "../src/**/*", + "./**/*" + ] +} \ No newline at end of file diff --git a/Backend/tests/unit/validators/AdminCourseApproval.validator.test.ts b/Backend/tests/unit/validators/AdminCourseApproval.validator.test.ts new file mode 100644 index 00000000..9068ad48 --- /dev/null +++ b/Backend/tests/unit/validators/AdminCourseApproval.validator.test.ts @@ -0,0 +1,67 @@ +import { + ApproveCourseValidator, + RejectCourseValidator, +} from '@/validators/AdminCourseApproval.validator'; + +describe('ApproveCourseValidator', () => { + it('should pass with no body (comment optional)', () => { + const { error } = ApproveCourseValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with optional comment', () => { + const { error } = ApproveCourseValidator.validate({ + comment: 'Looks great!', + }); + expect(error).toBeUndefined(); + }); + + it('should fail when comment exceeds 1000 characters', () => { + const { error } = ApproveCourseValidator.validate({ + comment: 'a'.repeat(1001), + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/must not exceed 1000/i); + }); + + it('should pass with comment exactly 1000 characters', () => { + const { error } = ApproveCourseValidator.validate({ + comment: 'a'.repeat(1000), + }); + expect(error).toBeUndefined(); + }); +}); + +describe('RejectCourseValidator', () => { + it('should pass with valid rejection comment', () => { + const { error } = RejectCourseValidator.validate({ + comment: 'The content is incomplete and needs more details.', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without comment', () => { + const { error } = RejectCourseValidator.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Comment is required when rejecting/i); + }); + + it('should fail when comment is too short (< 10 chars)', () => { + const { error } = RejectCourseValidator.validate({ comment: 'Too short' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 10 characters/i); + }); + + it('should pass with exactly 10 characters', () => { + const { error } = RejectCourseValidator.validate({ comment: '1234567890' }); + expect(error).toBeUndefined(); + }); + + it('should fail when comment exceeds 1000 characters', () => { + const { error } = RejectCourseValidator.validate({ + comment: 'a'.repeat(1001), + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/must not exceed 1000/i); + }); +}); diff --git a/Backend/tests/unit/validators/ChaptersLesson.validator.test.ts b/Backend/tests/unit/validators/ChaptersLesson.validator.test.ts new file mode 100644 index 00000000..7e80bc68 --- /dev/null +++ b/Backend/tests/unit/validators/ChaptersLesson.validator.test.ts @@ -0,0 +1,263 @@ +import { + CreateChapterValidator, + UpdateChapterValidator, + ReorderChapterValidator, + CreateLessonValidator, + UpdateLessonValidator, + ReorderLessonsValidator, + AddQuestionValidator, + UpdateQuizValidator, +} from '@/validators/ChaptersLesson.validator'; + + +// ============================================================ +// Chapter Validators +// ============================================================ + +describe('CreateChapterValidator', () => { + it('should pass with valid data', () => { + const { error } = CreateChapterValidator.validate({ + title: { th: 'āļšāļ—āļ—āļĩāđˆ 1', en: 'Chapter 1' }, + }); + expect(error).toBeUndefined(); + }); + + it('should pass with optional fields', () => { + const { error } = CreateChapterValidator.validate({ + title: { th: 'āļšāļ—āļ—āļĩāđˆ 1', en: 'Chapter 1' }, + description: { th: 'āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ', en: 'Description' }, + sort_order: 0, + }); + expect(error).toBeUndefined(); + }); + + it('should fail if title is missing', () => { + const { error } = CreateChapterValidator.validate({}); + expect(error).toBeDefined(); + }); + + it('should fail if title.th is missing', () => { + const { error } = CreateChapterValidator.validate({ + title: { en: 'Chapter 1' }, + }); + expect(error).toBeDefined(); + }); + + it('should fail if title.en is missing', () => { + const { error } = CreateChapterValidator.validate({ + title: { th: 'āļšāļ—āļ—āļĩāđˆ 1' }, + }); + expect(error).toBeDefined(); + }); +}); + +describe('UpdateChapterValidator', () => { + it('should pass with empty object (all fields optional)', () => { + const { error } = UpdateChapterValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with partial fields', () => { + const { error } = UpdateChapterValidator.validate({ + is_published: true, + }); + expect(error).toBeUndefined(); + }); +}); + +describe('ReorderChapterValidator', () => { + it('should pass with valid sort_order', () => { + const { error } = ReorderChapterValidator.validate({ sort_order: 0 }); + expect(error).toBeUndefined(); + }); + + it('should fail without sort_order', () => { + const { error } = ReorderChapterValidator.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Sort order is required/i); + }); + + it('should fail with negative sort_order', () => { + const { error } = ReorderChapterValidator.validate({ sort_order: -1 }); + expect(error).toBeDefined(); + }); +}); + +// ============================================================ +// Lesson Validators +// ============================================================ + +describe('CreateLessonValidator', () => { + it('should pass with VIDEO type', () => { + const { error } = CreateLessonValidator.validate({ + title: { th: 'āļšāļ—āđ€āļĢāļĩāļĒāļ™ 1', en: 'Lesson 1' }, + type: 'VIDEO', + }); + expect(error).toBeUndefined(); + }); + + it('should pass with QUIZ type', () => { + const { error } = CreateLessonValidator.validate({ + title: { th: 'āđāļšāļšāļ—āļ”āļŠāļ­āļš', en: 'Quiz' }, + type: 'QUIZ', + }); + expect(error).toBeUndefined(); + }); + + it('should fail with invalid type', () => { + const { error } = CreateLessonValidator.validate({ + title: { th: 'āļšāļ—āđ€āļĢāļĩāļĒāļ™', en: 'Lesson' }, + type: 'DOCUMENT', + }); + expect(error).toBeDefined(); + }); + + it('should fail without title', () => { + const { error } = CreateLessonValidator.validate({ type: 'VIDEO' }); + expect(error).toBeDefined(); + }); + + it('should fail without type', () => { + const { error } = CreateLessonValidator.validate({ + title: { th: 'āļšāļ—āđ€āļĢāļĩāļĒāļ™', en: 'Lesson' }, + }); + expect(error).toBeDefined(); + }); +}); + +describe('UpdateLessonValidator — prerequisite_lesson_ids', () => { + it('should pass with valid array of ids', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: [1, 2, 3], + }); + expect(error).toBeUndefined(); + }); + + it('should pass with null (clear prerequisites)', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: null, + }); + expect(error).toBeUndefined(); + }); + + it('should pass with empty array (clear prerequisites)', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: [], + }); + expect(error).toBeUndefined(); + }); + + it('should pass without the field (no change)', () => { + const { error } = UpdateLessonValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should fail with non-integer ids', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: [1.5], + }); + expect(error).toBeDefined(); + }); + + it('should fail with negative ids', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: [-1], + }); + expect(error).toBeDefined(); + }); + + it('should fail with string ids', () => { + const { error } = UpdateLessonValidator.validate({ + prerequisite_lesson_ids: ['abc'], + }); + expect(error).toBeDefined(); + }); +}); + +describe('ReorderLessonsValidator', () => { + it('should pass with valid data', () => { + const { error } = ReorderLessonsValidator.validate({ + lesson_id: 1, + sort_order: 0, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without lesson_id', () => { + const { error } = ReorderLessonsValidator.validate({ sort_order: 0 }); + expect(error).toBeDefined(); + }); + + it('should fail without sort_order', () => { + const { error } = ReorderLessonsValidator.validate({ lesson_id: 1 }); + expect(error).toBeDefined(); + }); +}); + +// ============================================================ +// Quiz Validators +// ============================================================ + +describe('AddQuestionValidator', () => { + it('should pass with MULTIPLE_CHOICE type + choices', () => { + const { error } = AddQuestionValidator.validate({ + question: { th: 'āļ‚āđ‰āļ­āļ—āļĩāđˆ 1 āļ„āļ·āļ­āļ­āļ°āđ„āļĢ?', en: 'What is question 1?' }, + question_type: 'MULTIPLE_CHOICE', + choices: [ + { text: { th: 'āļ', en: 'A' }, is_correct: true }, + { text: { th: 'āļ‚', en: 'B' }, is_correct: false }, + ], + }); + expect(error).toBeUndefined(); + }); + + it('should pass with TRUE_FALSE type without choices', () => { + const { error } = AddQuestionValidator.validate({ + question: { th: 'āļ–āļđāļāļŦāļĢāļ·āļ­āļœāļīāļ”?', en: 'True or False?' }, + question_type: 'TRUE_FALSE', + }); + expect(error).toBeUndefined(); + }); + + it('should fail with invalid question_type', () => { + const { error } = AddQuestionValidator.validate({ + question: { th: 'āļ„āļģāļ–āļēāļĄ', en: 'Question' }, + question_type: 'ESSAY', + }); + expect(error).toBeDefined(); + }); + + it('should fail without question', () => { + const { error } = AddQuestionValidator.validate({ + question_type: 'TRUE_FALSE', + }); + expect(error).toBeDefined(); + }); +}); + +describe('UpdateQuizValidator', () => { + it('should pass with empty object (all optional)', () => { + const { error } = UpdateQuizValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with valid passing_score', () => { + const { error } = UpdateQuizValidator.validate({ passing_score: 70 }); + expect(error).toBeUndefined(); + }); + + it('should fail with passing_score > 100', () => { + const { error } = UpdateQuizValidator.validate({ passing_score: 101 }); + expect(error).toBeDefined(); + }); + + it('should fail with passing_score < 0', () => { + const { error } = UpdateQuizValidator.validate({ passing_score: -1 }); + expect(error).toBeDefined(); + }); + + it('should pass with time_limit 0 (no limit)', () => { + const { error } = UpdateQuizValidator.validate({ time_limit: 0 }); + expect(error).toBeUndefined(); + }); +}); diff --git a/Backend/tests/unit/validators/CoursesInstructor.validator.test.ts b/Backend/tests/unit/validators/CoursesInstructor.validator.test.ts new file mode 100644 index 00000000..95c13c07 --- /dev/null +++ b/Backend/tests/unit/validators/CoursesInstructor.validator.test.ts @@ -0,0 +1,150 @@ +import { + CreateCourseValidator, + UpdateCourseValidator, + CloneCourseValidator, + addInstructorCourseValidator, +} from '@/validators/CoursesInstructor.validator'; + +// ============================================================ +// addInstructorCourseValidator +// ============================================================ + +describe('addInstructorCourseValidator', () => { + it('should pass with valid user_id and course_id', () => { + const { error } = addInstructorCourseValidator.validate({ + user_id: 1, + course_id: 2, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without user_id', () => { + const { error } = addInstructorCourseValidator.validate({ course_id: 2 }); + expect(error).toBeDefined(); + }); + + it('should fail without course_id', () => { + const { error } = addInstructorCourseValidator.validate({ user_id: 1 }); + expect(error).toBeDefined(); + }); +}); + +// ============================================================ +// CreateCourseValidator +// ============================================================ + +describe('CreateCourseValidator', () => { + const validPayload = { + category_id: 1, + title: { th: 'āļ„āļ­āļĢāđŒāļŠāļ—āļ”āļŠāļ­āļš', en: 'Test Course' }, + slug: 'test-course', + description: { th: 'āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ', en: 'Description' }, + price: 500, + is_free: false, + have_certificate: true, + }; + + it('should pass with all required fields', () => { + const { error } = CreateCourseValidator.validate(validPayload); + expect(error).toBeUndefined(); + }); + + it('should fail if title.th is missing', () => { + const { error } = CreateCourseValidator.validate({ + ...validPayload, + title: { en: 'Test Course' }, + }); + expect(error).toBeDefined(); + }); + + it('should fail if slug is missing', () => { + const { error } = CreateCourseValidator.validate({ + ...validPayload, + slug: undefined, + }); + expect(error).toBeDefined(); + }); + + it('should fail if price is missing', () => { + const { error } = CreateCourseValidator.validate({ + ...validPayload, + price: undefined, + }); + expect(error).toBeDefined(); + }); + + it('should fail if is_free is missing', () => { + const { error } = CreateCourseValidator.validate({ + ...validPayload, + is_free: undefined, + }); + expect(error).toBeDefined(); + }); + + it('should allow price = 0 (free course)', () => { + const { error } = CreateCourseValidator.validate({ + ...validPayload, + price: 0, + is_free: true, + }); + expect(error).toBeUndefined(); + }); +}); + +// ============================================================ +// UpdateCourseValidator +// ============================================================ + +describe('UpdateCourseValidator', () => { + it('should pass with empty object (all optional)', () => { + const { error } = UpdateCourseValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with partial update', () => { + const { error } = UpdateCourseValidator.validate({ price: 999 }); + expect(error).toBeUndefined(); + }); + + it('should pass with title partial update (th only)', () => { + const { error } = UpdateCourseValidator.validate({ + title: { th: 'āļŠāļ·āđˆāļ­āđƒāļŦāļĄāđˆ' }, + }); + expect(error).toBeUndefined(); + }); +}); + +// ============================================================ +// CloneCourseValidator +// ============================================================ + +describe('CloneCourseValidator', () => { + it('should pass with valid title', () => { + const { error } = CloneCourseValidator.validate({ + title: { th: 'āļ„āļ­āļĢāđŒāļŠ Copy', en: 'Course Copy' }, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without title.th', () => { + const { error } = CloneCourseValidator.validate({ + title: { en: 'Course Copy' }, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Thai title is required/i); + }); + + it('should fail without title.en', () => { + const { error } = CloneCourseValidator.validate({ + title: { th: 'āļ„āļ­āļĢāđŒāļŠ Copy' }, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/English title is required/i); + }); + + it('should fail without title entirely', () => { + const { error } = CloneCourseValidator.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Title is required/i); + }); +}); diff --git a/Backend/tests/unit/validators/CoursesStudent.validator.test.ts b/Backend/tests/unit/validators/CoursesStudent.validator.test.ts new file mode 100644 index 00000000..ae9569c4 --- /dev/null +++ b/Backend/tests/unit/validators/CoursesStudent.validator.test.ts @@ -0,0 +1,96 @@ +import { + SaveVideoProgressValidator, + SubmitQuizValidator, +} from '@/validators/CoursesStudent.validator'; + +describe('SaveVideoProgressValidator', () => { + it('should pass with required field only', () => { + const { error } = SaveVideoProgressValidator.validate({ + video_progress_seconds: 60, + }); + expect(error).toBeUndefined(); + }); + + it('should pass with all fields', () => { + const { error } = SaveVideoProgressValidator.validate({ + video_progress_seconds: 120, + video_duration_seconds: 600, + }); + expect(error).toBeUndefined(); + }); + + it('should pass with progress = 0 (start of video)', () => { + const { error } = SaveVideoProgressValidator.validate({ + video_progress_seconds: 0, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without video_progress_seconds', () => { + const { error } = SaveVideoProgressValidator.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Video progress seconds is required/i); + }); + + it('should fail with negative progress', () => { + const { error } = SaveVideoProgressValidator.validate({ + video_progress_seconds: -1, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 0/i); + }); + + it('should fail with negative video duration', () => { + const { error } = SaveVideoProgressValidator.validate({ + video_progress_seconds: 10, + video_duration_seconds: -1, + }); + expect(error).toBeDefined(); + }); +}); + +describe('SubmitQuizValidator', () => { + const validAnswer = { question_id: 1, choice_id: 2 }; + + it('should pass with valid answers', () => { + const { error } = SubmitQuizValidator.validate({ + answers: [validAnswer, { question_id: 2, choice_id: 5 }], + }); + expect(error).toBeUndefined(); + }); + + it('should fail without answers', () => { + const { error } = SubmitQuizValidator.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Answers are required/i); + }); + + it('should fail with empty answers array', () => { + const { error } = SubmitQuizValidator.validate({ answers: [] }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/At least one answer/i); + }); + + it('should fail if question_id is missing in an answer', () => { + const { error } = SubmitQuizValidator.validate({ + answers: [{ choice_id: 2 }], + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Question ID is required/i); + }); + + it('should fail if choice_id is missing in an answer', () => { + const { error } = SubmitQuizValidator.validate({ + answers: [{ question_id: 1 }], + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Choice ID is required/i); + }); + + it('should fail if question_id is not a positive integer', () => { + const { error } = SubmitQuizValidator.validate({ + answers: [{ question_id: -1, choice_id: 1 }], + }); + expect(error).toBeDefined(); + }); +}); diff --git a/Backend/tests/unit/validators/Lessons.validator.test.ts b/Backend/tests/unit/validators/Lessons.validator.test.ts new file mode 100644 index 00000000..81ef5471 --- /dev/null +++ b/Backend/tests/unit/validators/Lessons.validator.test.ts @@ -0,0 +1,45 @@ +import { SetYouTubeVideoValidator } from '@/validators/Lessons.validator'; + +describe('SetYouTubeVideoValidator', () => { + it('should pass with valid youtube_video_id and video_title', () => { + const { error } = SetYouTubeVideoValidator.validate({ + youtube_video_id: 'dQw4w9WgXcQ', + video_title: 'Introduction to TypeScript', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without youtube_video_id', () => { + const { error } = SetYouTubeVideoValidator.validate({ + video_title: 'Intro to TS', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/YouTube video ID is required/i); + }); + + it('should fail with empty youtube_video_id string', () => { + const { error } = SetYouTubeVideoValidator.validate({ + youtube_video_id: '', + video_title: 'Intro to TS', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/cannot be empty/i); + }); + + it('should fail without video_title', () => { + const { error } = SetYouTubeVideoValidator.validate({ + youtube_video_id: 'dQw4w9WgXcQ', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Video title is required/i); + }); + + it('should fail with empty video_title string', () => { + const { error } = SetYouTubeVideoValidator.validate({ + youtube_video_id: 'dQw4w9WgXcQ', + video_title: '', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/cannot be empty/i); + }); +}); diff --git a/Backend/tests/unit/validators/announcements.validator.test.ts b/Backend/tests/unit/validators/announcements.validator.test.ts new file mode 100644 index 00000000..9e186ca2 --- /dev/null +++ b/Backend/tests/unit/validators/announcements.validator.test.ts @@ -0,0 +1,115 @@ +import { + CreateAnnouncementValidator, + UpdateAnnouncementValidator, +} from '@/validators/announcements.validator'; + +describe('CreateAnnouncementValidator', () => { + const validPayload = { + title: { th: 'āļ›āļĢāļ°āļāļēāļĻāđƒāļŦāļĄāđˆ', en: 'New Announcement' }, + content: { th: 'āđ€āļ™āļ·āđ‰āļ­āļŦāļē', en: 'Content' }, + status: 'DRAFT', + is_pinned: false, + }; + + it('should pass with all required fields', () => { + const { error } = CreateAnnouncementValidator.validate(validPayload); + expect(error).toBeUndefined(); + }); + + it('should pass with optional published_at as ISO date', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + published_at: '2026-03-01T00:00:00.000Z', + }); + expect(error).toBeUndefined(); + }); + + it('should fail with invalid published_at format', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + published_at: '01-03-2026', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/ISO date/i); + }); + + it('should fail with invalid status', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + status: 'HIDDEN', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/DRAFT, PUBLISHED, ARCHIVED/i); + }); + + it('should pass with PUBLISHED status', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + status: 'PUBLISHED', + }); + expect(error).toBeUndefined(); + }); + + it('should pass with ARCHIVED status', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + status: 'ARCHIVED', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without title', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + title: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Title is required/i); + }); + + it('should fail without content', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + content: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Content is required/i); + }); + + it('should fail without is_pinned', () => { + const { error } = CreateAnnouncementValidator.validate({ + ...validPayload, + is_pinned: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/is_pinned is required/i); + }); +}); + +describe('UpdateAnnouncementValidator', () => { + it('should pass with empty object (all optional)', () => { + const { error } = UpdateAnnouncementValidator.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with partial update', () => { + const { error } = UpdateAnnouncementValidator.validate({ + status: 'PUBLISHED', + is_pinned: true, + }); + expect(error).toBeUndefined(); + }); + + it('should fail with invalid status', () => { + const { error } = UpdateAnnouncementValidator.validate({ status: 'DELETED' }); + expect(error).toBeDefined(); + }); + + it('should fail with invalid published_at format', () => { + const { error } = UpdateAnnouncementValidator.validate({ + published_at: 'not-a-date', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/ISO date/i); + }); +}); diff --git a/Backend/tests/unit/validators/auth.validator.test.ts b/Backend/tests/unit/validators/auth.validator.test.ts new file mode 100644 index 00000000..2ef38652 --- /dev/null +++ b/Backend/tests/unit/validators/auth.validator.test.ts @@ -0,0 +1,246 @@ +import { + loginSchema, + registerSchema, + refreshTokenSchema, + resetPasswordSchema, + changePasswordSchema, + resetRequestSchema, +} from '@/validators/auth.validator'; + +// ============================================================ +// loginSchema +// ============================================================ + +describe('loginSchema', () => { + it('should pass with valid email and password', () => { + const { error } = loginSchema.validate({ + email: 'user@example.com', + password: 'password123', + }); + expect(error).toBeUndefined(); + }); + + it('should fail with invalid email format', () => { + const { error } = loginSchema.validate({ + email: 'not-an-email', + password: 'password123', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/valid email/i); + }); + + it('should fail without email', () => { + const { error } = loginSchema.validate({ password: 'password123' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Email is required/i); + }); + + it('should fail without password', () => { + const { error } = loginSchema.validate({ email: 'user@example.com' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Password is required/i); + }); + + it('should fail with password shorter than 6 characters', () => { + const { error } = loginSchema.validate({ + email: 'user@example.com', + password: '12345', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 6 characters/i); + }); +}); + +// ============================================================ +// registerSchema +// ============================================================ + +describe('registerSchema', () => { + const validPayload = { + username: 'john_doe', + email: 'john@example.com', + password: 'securepass', + first_name: 'John', + last_name: 'Doe', + phone: '0812345678', + }; + + it('should pass with all required fields', () => { + const { error } = registerSchema.validate(validPayload); + expect(error).toBeUndefined(); + }); + + it('should pass with optional prefix', () => { + const { error } = registerSchema.validate({ + ...validPayload, + prefix: { th: 'āļ™āļēāļĒ', en: 'Mr.' }, + }); + expect(error).toBeUndefined(); + }); + + it('should fail if username has invalid characters', () => { + const { error } = registerSchema.validate({ + ...validPayload, + username: 'john doe!', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/letters, numbers, and underscores/i); + }); + + it('should fail if username is too short (< 3 chars)', () => { + const { error } = registerSchema.validate({ + ...validPayload, + username: 'ab', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 3 characters/i); + }); + + it('should fail if username is too long (> 50 chars)', () => { + const { error } = registerSchema.validate({ + ...validPayload, + username: 'a'.repeat(51), + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/not exceed 50 characters/i); + }); + + it('should fail with invalid email', () => { + const { error } = registerSchema.validate({ + ...validPayload, + email: 'bad-email', + }); + expect(error).toBeDefined(); + }); + + it('should fail if phone is too short (< 10 chars)', () => { + const { error } = registerSchema.validate({ + ...validPayload, + phone: '081234', + }); + expect(error).toBeDefined(); + }); + + it('should fail without first_name', () => { + const { error } = registerSchema.validate({ + ...validPayload, + first_name: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/First name is required/i); + }); + + it('should fail without last_name', () => { + const { error } = registerSchema.validate({ + ...validPayload, + last_name: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Last name is required/i); + }); +}); + +// ============================================================ +// refreshTokenSchema +// ============================================================ + +describe('refreshTokenSchema', () => { + it('should pass with a valid refreshToken', () => { + const { error } = refreshTokenSchema.validate({ + refreshToken: 'some-refresh-token-string', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without refreshToken', () => { + const { error } = refreshTokenSchema.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Refresh token is required/i); + }); +}); + +// ============================================================ +// resetPasswordSchema +// ============================================================ + +describe('resetPasswordSchema', () => { + it('should pass with valid token and password', () => { + const { error } = resetPasswordSchema.validate({ + token: 'reset-token-abc', + password: 'newpassword', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without token', () => { + const { error } = resetPasswordSchema.validate({ password: 'newpassword' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Reset token is required/i); + }); + + it('should fail with password too short', () => { + const { error } = resetPasswordSchema.validate({ + token: 'abc', + password: '12345', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 6 characters/i); + }); + + it('should fail with password too long (> 100 chars)', () => { + const { error } = resetPasswordSchema.validate({ + token: 'abc', + password: 'a'.repeat(101), + }); + expect(error).toBeDefined(); + }); +}); + +// ============================================================ +// changePasswordSchema (auth) +// ============================================================ + +describe('changePasswordSchema (auth.validator)', () => { + it('should pass with valid old and new passwords', () => { + const { error } = changePasswordSchema.validate({ + oldPassword: 'oldpass123', + newPassword: 'newpass456', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without oldPassword', () => { + const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Password is required/i); + }); + + it('should fail without newPassword', () => { + const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Password is required/i); + }); +}); + +// ============================================================ +// resetRequestSchema +// ============================================================ + +describe('resetRequestSchema', () => { + it('should pass with valid email', () => { + const { error } = resetRequestSchema.validate({ email: 'user@example.com' }); + expect(error).toBeUndefined(); + }); + + it('should fail without email', () => { + const { error } = resetRequestSchema.validate({}); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Email is required/i); + }); + + it('should fail with invalid email', () => { + const { error } = resetRequestSchema.validate({ email: 'not-email' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/valid email/i); + }); +}); diff --git a/Backend/tests/unit/validators/categories.validator.test.ts b/Backend/tests/unit/validators/categories.validator.test.ts new file mode 100644 index 00000000..64249c2a --- /dev/null +++ b/Backend/tests/unit/validators/categories.validator.test.ts @@ -0,0 +1,121 @@ +import { + CreateCategoryValidator, + UpdateCategoryValidator, +} from '@/validators/categories.validator'; + +describe('CreateCategoryValidator', () => { + const validPayload = { + name: { th: 'āļāļēāļĢāļžāļąāļ’āļ™āļēāđ€āļ§āđ‡āļš', en: 'Web Development' }, + slug: 'web-development', + description: { th: 'āļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāļāļēāļĢāļžāļąāļ’āļ™āļēāđ€āļ§āđ‡āļš', en: 'Web development category' }, + }; + + it('should pass with all required fields', () => { + const { error } = CreateCategoryValidator.validate(validPayload); + expect(error).toBeUndefined(); + }); + + it('should pass with optional created_by', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + created_by: 1, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without name', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + name: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Name is required/i); + }); + + it('should fail without name.th', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + name: { en: 'Web Development' }, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Thai name is required/i); + }); + + it('should fail without slug', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + slug: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Slug is required/i); + }); + + it('should fail with invalid slug format (uppercase)', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + slug: 'Web-Development', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/lowercase with hyphens/i); + }); + + it('should fail with invalid slug format (spaces)', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + slug: 'web development', + }); + expect(error).toBeDefined(); + }); + + it('should pass with valid slug formats', () => { + const slugs = ['web', 'web-dev', 'web-development-101']; + for (const slug of slugs) { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + slug, + }); + expect(error).toBeUndefined(); + } + }); + + it('should fail without description', () => { + const { error } = CreateCategoryValidator.validate({ + ...validPayload, + description: undefined, + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Description is required/i); + }); +}); + +describe('UpdateCategoryValidator', () => { + it('should pass with only required id', () => { + const { error } = UpdateCategoryValidator.validate({ id: 1 }); + expect(error).toBeUndefined(); + }); + + it('should pass with all optional fields', () => { + const { error } = UpdateCategoryValidator.validate({ + id: 1, + name: { th: 'āđƒāļŦāļĄāđˆ', en: 'New' }, + slug: 'new-category', + description: { th: 'āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ', en: 'Description' }, + }); + expect(error).toBeUndefined(); + }); + + it('should fail without id', () => { + const { error } = UpdateCategoryValidator.validate({ name: { th: 'āđƒāļŦāļĄāđˆ', en: 'New' } }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Category ID is required/i); + }); + + it('should fail with invalid slug format', () => { + const { error } = UpdateCategoryValidator.validate({ + id: 1, + slug: 'Invalid Slug!', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/lowercase with hyphens/i); + }); +}); diff --git a/Backend/tests/unit/validators/user.validator.test.ts b/Backend/tests/unit/validators/user.validator.test.ts new file mode 100644 index 00000000..349ceaa4 --- /dev/null +++ b/Backend/tests/unit/validators/user.validator.test.ts @@ -0,0 +1,100 @@ +import { + profileUpdateSchema, + changePasswordSchema, +} from '@/validators/user.validator'; + +describe('profileUpdateSchema', () => { + it('should pass with empty object (all optional)', () => { + const { error } = profileUpdateSchema.validate({}); + expect(error).toBeUndefined(); + }); + + it('should pass with all fields', () => { + const { error } = profileUpdateSchema.validate({ + prefix: { th: 'āļ™āļēāļĒ', en: 'Mr.' }, + first_name: 'John', + last_name: 'Doe', + phone: '0812345678', + avatar_url: 'https://example.com/avatar.jpg', + birth_date: new Date('1990-01-01'), + }); + expect(error).toBeUndefined(); + }); + + it('should pass with partial update (first_name only)', () => { + const { error } = profileUpdateSchema.validate({ first_name: 'Jane' }); + expect(error).toBeUndefined(); + }); + + it('should fail if first_name is empty string', () => { + const { error } = profileUpdateSchema.validate({ first_name: '' }); + expect(error).toBeDefined(); + }); + + it('should fail if first_name exceeds 100 characters', () => { + const { error } = profileUpdateSchema.validate({ first_name: 'a'.repeat(101) }); + expect(error).toBeDefined(); + }); + + it('should fail if phone is too short (< 10 chars)', () => { + const { error } = profileUpdateSchema.validate({ phone: '081234' }); + expect(error).toBeDefined(); + }); + + it('should fail if phone exceeds 15 characters', () => { + const { error } = profileUpdateSchema.validate({ phone: '1'.repeat(16) }); + expect(error).toBeDefined(); + }); + + it('should pass with valid birth_date', () => { + const { error } = profileUpdateSchema.validate({ birth_date: new Date('2000-06-15') }); + expect(error).toBeUndefined(); + }); +}); + +describe('changePasswordSchema (user.validator)', () => { + it('should pass with valid old and new passwords', () => { + const { error } = changePasswordSchema.validate({ + oldPassword: 'oldpass123', + newPassword: 'newpass456', + }); + expect(error).toBeUndefined(); + }); + + it('should fail without oldPassword', () => { + const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Old password is required/i); + }); + + it('should fail without newPassword', () => { + const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/New password is required/i); + }); + + it('should fail if oldPassword is shorter than 6 chars', () => { + const { error } = changePasswordSchema.validate({ + oldPassword: '12345', + newPassword: 'newpass456', + }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/at least 6 characters/i); + }); + + it('should fail if newPassword is shorter than 6 chars', () => { + const { error } = changePasswordSchema.validate({ + oldPassword: 'oldpass123', + newPassword: '123', + }); + expect(error).toBeDefined(); + }); + + it('should fail if oldPassword exceeds 100 characters', () => { + const { error } = changePasswordSchema.validate({ + oldPassword: 'a'.repeat(101), + newPassword: 'newpass456', + }); + expect(error).toBeDefined(); + }); +}); diff --git a/Backend/tests/unit/validators/usermanagement.validator.test.ts b/Backend/tests/unit/validators/usermanagement.validator.test.ts new file mode 100644 index 00000000..6d57de81 --- /dev/null +++ b/Backend/tests/unit/validators/usermanagement.validator.test.ts @@ -0,0 +1,59 @@ +import { + getUserByIdValidator, + updateUserRoleValidator, +} from '@/validators/usermanagement.validator'; + +describe('getUserByIdValidator', () => { + it('should pass with valid id', () => { + const { error } = getUserByIdValidator.validate({ id: 1 }); + expect(error).toBeUndefined(); + }); + + it('should fail without id', () => { + const { error } = getUserByIdValidator.validate({}); + expect(error).toBeDefined(); + }); + + it('should fail with non-numeric id', () => { + const { error } = getUserByIdValidator.validate({ id: 'abc' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/ID must be a number/i); + }); + + it('should pass with id = 0', () => { + // Joi number() allows 0 by default unless positive() is specified + const { error } = getUserByIdValidator.validate({ id: 0 }); + expect(error).toBeUndefined(); + }); +}); + +describe('updateUserRoleValidator', () => { + it('should pass with valid id and role_id', () => { + const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 2 }); + expect(error).toBeUndefined(); + }); + + it('should fail without id', () => { + const { error } = updateUserRoleValidator.validate({ role_id: 2 }); + expect(error).toBeDefined(); + }); + + it('should fail without role_id', () => { + const { error } = updateUserRoleValidator.validate({ id: 1 }); + expect(error).toBeDefined(); + // Joi uses field name in message when custom messages don't match + expect(error?.details[0].message).toContain('role_id'); + }); + + it('should fail with non-numeric role_id', () => { + const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 'admin' }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/Role ID must be a number/i); + }); + + it('should fail with non-numeric id', () => { + const { error } = updateUserRoleValidator.validate({ id: 'abc', role_id: 1 }); + expect(error).toBeDefined(); + expect(error?.details[0].message).toMatch(/ID must be a number/i); + }); +}); diff --git a/Backend/tsconfig.test.json b/Backend/tsconfig.test.json new file mode 100644 index 00000000..5eb9ef95 --- /dev/null +++ b/Backend/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + }, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "src/**/*", + "tests/**/*" + ] +} \ No newline at end of file diff --git a/Frontend-Learner/.nuxtrc b/Frontend-Learner/.nuxtrc new file mode 100644 index 00000000..1e1fe833 --- /dev/null +++ b/Frontend-Learner/.nuxtrc @@ -0,0 +1 @@ +setups.@nuxt/test-utils="4.0.0" \ No newline at end of file diff --git a/Frontend-Learner/Dockerfile b/Frontend-Learner/Dockerfile index 1e21249f..15637029 100644 --- a/Frontend-Learner/Dockerfile +++ b/Frontend-Learner/Dockerfile @@ -7,8 +7,8 @@ WORKDIR /app # āļ„āļąāļ”āļĨāļ­āļāđ„āļŸāļĨāđŒāļˆāļąāļ”āļāļēāļĢ dependencies COPY package*.json ./ -# āļ•āļīāļ”āļ•āļąāđ‰āļ‡ dependencies (āđƒāļŠāđ‰ npm ci āđ€āļžāļ·āđˆāļ­āļ„āļ§āļēāļĄāđāļĄāđˆāļ™āļĒāļģāļ‚āļ­āļ‡āđ€āļ§āļ­āļĢāđŒāļŠāļąāļ™) -RUN npm ci +# āļ•āļīāļ”āļ•āļąāđ‰āļ‡ dependencies +RUN npm install # āļ„āļąāļ”āļĨāļ­āļāđ„āļŸāļĨāđŒāļ—āļąāđ‰āļ‡āļŦāļĄāļ”āđƒāļ™āđ‚āļ›āļĢāđ€āļˆāļāļ•āđŒ COPY . . diff --git a/Frontend-Learner/app.vue b/Frontend-Learner/app.vue index 8070d468..1dfac678 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -27,8 +27,8 @@ onMounted(() => { diff --git a/frontend_management/pages/instructor/profile/index.vue b/frontend_management/pages/instructor/profile/index.vue index 6a602a39..6a36b365 100644 --- a/frontend_management/pages/instructor/profile/index.vue +++ b/frontend_management/pages/instructor/profile/index.vue @@ -301,7 +301,7 @@ + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/1ee1d6d7d57c179e96128e40db55227dd86a7817.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/1ee1d6d7d57c179e96128e40db55227dd86a7817.png new file mode 100644 index 00000000..db3ac331 Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/1ee1d6d7d57c179e96128e40db55227dd86a7817.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/35df25d6cdde602ee836a293f5167004c1a5fa77.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/35df25d6cdde602ee836a293f5167004c1a5fa77.png new file mode 100644 index 00000000..944b8caa Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/35df25d6cdde602ee836a293f5167004c1a5fa77.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/4090ba8133de6c748a59d2da9b782a04fc4c6db0.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/4090ba8133de6c748a59d2da9b782a04fc4c6db0.png new file mode 100644 index 00000000..4592b7ed Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/4090ba8133de6c748a59d2da9b782a04fc4c6db0.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/41e85c99c7d03cc714e887a691300a72ec49360c.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/41e85c99c7d03cc714e887a691300a72ec49360c.png new file mode 100644 index 00000000..a1fc23c8 Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/41e85c99c7d03cc714e887a691300a72ec49360c.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/65fdbc416f0e24dec95ce26a8d821ef9cb3765bf.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/65fdbc416f0e24dec95ce26a8d821ef9cb3765bf.png new file mode 100644 index 00000000..8a399922 Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/65fdbc416f0e24dec95ce26a8d821ef9cb3765bf.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/827f0948bcc7200f737fc0a088783bbea297f50f.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/827f0948bcc7200f737fc0a088783bbea297f50f.png new file mode 100644 index 00000000..c5136041 Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/827f0948bcc7200f737fc0a088783bbea297f50f.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/c8902bf323a4786f68fa00ea84781ba7957d7d7a.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/c8902bf323a4786f68fa00ea84781ba7957d7d7a.png new file mode 100644 index 00000000..8b7350fe Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/c8902bf323a4786f68fa00ea84781ba7957d7d7a.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/ccf7ebb27025218191824d8efc9ca47e1c964b1e.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/ccf7ebb27025218191824d8efc9ca47e1c964b1e.png new file mode 100644 index 00000000..7d421301 Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/ccf7ebb27025218191824d8efc9ca47e1c964b1e.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/data/f25e29ca1b01c21b07dca63fe776c2605caf1ccd.png b/frontend_management/test_result/Admin/Categories_Page_Tests/data/f25e29ca1b01c21b07dca63fe776c2605caf1ccd.png new file mode 100644 index 00000000..64c9a1dd Binary files /dev/null and b/frontend_management/test_result/Admin/Categories_Page_Tests/data/f25e29ca1b01c21b07dca63fe776c2605caf1ccd.png differ diff --git a/frontend_management/test_result/Admin/Categories_Page_Tests/index.html b/frontend_management/test_result/Admin/Categories_Page_Tests/index.html new file mode 100644 index 00000000..5f77a512 --- /dev/null +++ b/frontend_management/test_result/Admin/Categories_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Admin/Dashboard_Tests/data/0db70a62c5681aa2ef14d34994eccd60520d3e47.png b/frontend_management/test_result/Admin/Dashboard_Tests/data/0db70a62c5681aa2ef14d34994eccd60520d3e47.png new file mode 100644 index 00000000..0c421643 Binary files /dev/null and b/frontend_management/test_result/Admin/Dashboard_Tests/data/0db70a62c5681aa2ef14d34994eccd60520d3e47.png differ diff --git a/frontend_management/test_result/Admin/Dashboard_Tests/data/4c5d3eb5a2e8a33833fc35360a3b8a045628cb2d.png b/frontend_management/test_result/Admin/Dashboard_Tests/data/4c5d3eb5a2e8a33833fc35360a3b8a045628cb2d.png new file mode 100644 index 00000000..cc471d96 Binary files /dev/null and b/frontend_management/test_result/Admin/Dashboard_Tests/data/4c5d3eb5a2e8a33833fc35360a3b8a045628cb2d.png differ diff --git a/frontend_management/test_result/Admin/Dashboard_Tests/data/866d6a06b33921ae112d5bfea6d92b229038f4bd.png b/frontend_management/test_result/Admin/Dashboard_Tests/data/866d6a06b33921ae112d5bfea6d92b229038f4bd.png new file mode 100644 index 00000000..1e748d43 Binary files /dev/null and b/frontend_management/test_result/Admin/Dashboard_Tests/data/866d6a06b33921ae112d5bfea6d92b229038f4bd.png differ diff --git a/frontend_management/test_result/Admin/Dashboard_Tests/data/a5d4e4da3ea2fb373b41f4adbb13d2b24b8ccfe1.png b/frontend_management/test_result/Admin/Dashboard_Tests/data/a5d4e4da3ea2fb373b41f4adbb13d2b24b8ccfe1.png new file mode 100644 index 00000000..d113cb05 Binary files /dev/null and b/frontend_management/test_result/Admin/Dashboard_Tests/data/a5d4e4da3ea2fb373b41f4adbb13d2b24b8ccfe1.png differ diff --git a/frontend_management/test_result/Admin/Dashboard_Tests/index.html b/frontend_management/test_result/Admin/Dashboard_Tests/index.html new file mode 100644 index 00000000..a7996631 --- /dev/null +++ b/frontend_management/test_result/Admin/Dashboard_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/0453f4201cd452cfa653e6439a05e46caba79eb8.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/0453f4201cd452cfa653e6439a05e46caba79eb8.png new file mode 100644 index 00000000..b939642c Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/0453f4201cd452cfa653e6439a05e46caba79eb8.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/2efbe1198619aae53dd8768e53e81002d0b357a5.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/2efbe1198619aae53dd8768e53e81002d0b357a5.png new file mode 100644 index 00000000..6ed877f9 Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/2efbe1198619aae53dd8768e53e81002d0b357a5.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/528b1769e5aa0373f08be5b1e93ca6bd4de71183.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/528b1769e5aa0373f08be5b1e93ca6bd4de71183.png new file mode 100644 index 00000000..6e07ef8b Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/528b1769e5aa0373f08be5b1e93ca6bd4de71183.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/6c599641f8347eea9faef8a04424075e5fc3e8dc.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/6c599641f8347eea9faef8a04424075e5fc3e8dc.png new file mode 100644 index 00000000..941915f8 Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/6c599641f8347eea9faef8a04424075e5fc3e8dc.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/aa0f425a2702d00bbdd804a1d4ca9fadf55a6562.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/aa0f425a2702d00bbdd804a1d4ca9fadf55a6562.png new file mode 100644 index 00000000..9e224c65 Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/aa0f425a2702d00bbdd804a1d4ca9fadf55a6562.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/b051465650cc45a9db3430a37a44a4cd60e679dd.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/b051465650cc45a9db3430a37a44a4cd60e679dd.png new file mode 100644 index 00000000..1895e9f4 Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/b051465650cc45a9db3430a37a44a4cd60e679dd.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/c5561250ddaf7ee9f68edf5ab096f616efd38664.png b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/c5561250ddaf7ee9f68edf5ab096f616efd38664.png new file mode 100644 index 00000000..7fc15fdd Binary files /dev/null and b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/data/c5561250ddaf7ee9f68edf5ab096f616efd38664.png differ diff --git a/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/index.html b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/index.html new file mode 100644 index 00000000..c302dc7a --- /dev/null +++ b/frontend_management/test_result/Admin/Pending_Courses_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/069d36dffaf29e9ba9d4e6d0e2d50d69247e576d.png b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/069d36dffaf29e9ba9d4e6d0e2d50d69247e576d.png new file mode 100644 index 00000000..57078284 Binary files /dev/null and b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/069d36dffaf29e9ba9d4e6d0e2d50d69247e576d.png differ diff --git a/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/20201a029c254b88c8fcee02d0222ed560bca040.png b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/20201a029c254b88c8fcee02d0222ed560bca040.png new file mode 100644 index 00000000..36076d76 Binary files /dev/null and b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/20201a029c254b88c8fcee02d0222ed560bca040.png differ diff --git a/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/adb564f25d91be7a7a19adc86618e4c587c22852.png b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/adb564f25d91be7a7a19adc86618e4c587c22852.png new file mode 100644 index 00000000..b766c7aa Binary files /dev/null and b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/adb564f25d91be7a7a19adc86618e4c587c22852.png differ diff --git a/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/ae9d78c1d16c81f14166fcc263feb68daff0b337.png b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/ae9d78c1d16c81f14166fcc263feb68daff0b337.png new file mode 100644 index 00000000..a288a93b Binary files /dev/null and b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/data/ae9d78c1d16c81f14166fcc263feb68daff0b337.png differ diff --git a/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/index.html b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/index.html new file mode 100644 index 00000000..2e85b1f5 --- /dev/null +++ b/frontend_management/test_result/Admin/Recommended_Courses_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/01131b544f7ef5ab6ae9159c7b3aeb54a6a35ace.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/01131b544f7ef5ab6ae9159c7b3aeb54a6a35ace.png new file mode 100644 index 00000000..98b540e2 Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/01131b544f7ef5ab6ae9159c7b3aeb54a6a35ace.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/14505851a4cfcd340a2c15e4eede8ba1b00a45f3.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/14505851a4cfcd340a2c15e4eede8ba1b00a45f3.png new file mode 100644 index 00000000..6702afbb Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/14505851a4cfcd340a2c15e4eede8ba1b00a45f3.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/61c9b0535c23ea7767a4692c7b1cd5b7efa77198.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/61c9b0535c23ea7767a4692c7b1cd5b7efa77198.png new file mode 100644 index 00000000..e126ef23 Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/61c9b0535c23ea7767a4692c7b1cd5b7efa77198.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/8cd29873e393b6d0d06b99325f7d44448dba4e7d.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/8cd29873e393b6d0d06b99325f7d44448dba4e7d.png new file mode 100644 index 00000000..f6515336 Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/8cd29873e393b6d0d06b99325f7d44448dba4e7d.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/9136118b9393d5080e9936f45e76adb40b146c51.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/9136118b9393d5080e9936f45e76adb40b146c51.png new file mode 100644 index 00000000..c68d7f69 Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/9136118b9393d5080e9936f45e76adb40b146c51.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/a54e6793c83ac936ea2d687bc0846ceb6356f757.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/a54e6793c83ac936ea2d687bc0846ceb6356f757.png new file mode 100644 index 00000000..66015c9b Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/a54e6793c83ac936ea2d687bc0846ceb6356f757.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/c57dab7bdd78b963cffec28b4fc8d505bc610443.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/c57dab7bdd78b963cffec28b4fc8d505bc610443.png new file mode 100644 index 00000000..b4d47e28 Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/c57dab7bdd78b963cffec28b4fc8d505bc610443.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/data/f00e2afd169f0d440483247810ca3f3aa12fcb8e.png b/frontend_management/test_result/Admin/Users_Page_Tests/data/f00e2afd169f0d440483247810ca3f3aa12fcb8e.png new file mode 100644 index 00000000..602d399a Binary files /dev/null and b/frontend_management/test_result/Admin/Users_Page_Tests/data/f00e2afd169f0d440483247810ca3f3aa12fcb8e.png differ diff --git a/frontend_management/test_result/Admin/Users_Page_Tests/index.html b/frontend_management/test_result/Admin/Users_Page_Tests/index.html new file mode 100644 index 00000000..45ea8365 --- /dev/null +++ b/frontend_management/test_result/Admin/Users_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/3daa583ffad06b8cb14b557facad1bd9f955f649.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/3daa583ffad06b8cb14b557facad1bd9f955f649.png new file mode 100644 index 00000000..cbc5c2f8 Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/3daa583ffad06b8cb14b557facad1bd9f955f649.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6a663960da5071a13ba4d0efe05b15795a404dd5.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6a663960da5071a13ba4d0efe05b15795a404dd5.png new file mode 100644 index 00000000..09eac2d6 Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6a663960da5071a13ba4d0efe05b15795a404dd5.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6f367ab188ae80996e584f9b58e0a689e5b7736c.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6f367ab188ae80996e584f9b58e0a689e5b7736c.png new file mode 100644 index 00000000..74f116d3 Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/6f367ab188ae80996e584f9b58e0a689e5b7736c.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/7a7022d29cc41e4c91622d52c4d4bec33e5ea27b.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/7a7022d29cc41e4c91622d52c4d4bec33e5ea27b.png new file mode 100644 index 00000000..3a55d20c Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/7a7022d29cc41e4c91622d52c4d4bec33e5ea27b.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/bb4d5765838ec53396637363d765fe9999a23cef.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/bb4d5765838ec53396637363d765fe9999a23cef.png new file mode 100644 index 00000000..2ad8b85c Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/bb4d5765838ec53396637363d765fe9999a23cef.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c5a76b0d67d10db9794f5eaaa0ad93900dc66ef9.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c5a76b0d67d10db9794f5eaaa0ad93900dc66ef9.png new file mode 100644 index 00000000..41ba0c17 Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c5a76b0d67d10db9794f5eaaa0ad93900dc66ef9.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c9db8fa9d3f7eebe20c935bc28970c686729b80e.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c9db8fa9d3f7eebe20c935bc28970c686729b80e.png new file mode 100644 index 00000000..a35dbe1a Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/c9db8fa9d3f7eebe20c935bc28970c686729b80e.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/d8269d490caef20a582ba9d9105b7d11a23a763a.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/d8269d490caef20a582ba9d9105b7d11a23a763a.png new file mode 100644 index 00000000..cbf6ae48 Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/d8269d490caef20a582ba9d9105b7d11a23a763a.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/fd5921cba1eca4f1439b107ba699e33ec81efb98.png b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/fd5921cba1eca4f1439b107ba699e33ec81efb98.png new file mode 100644 index 00000000..7731b60c Binary files /dev/null and b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/data/fd5921cba1eca4f1439b107ba699e33ec81efb98.png differ diff --git a/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/index.html b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/index.html new file mode 100644 index 00000000..3ca52779 --- /dev/null +++ b/frontend_management/test_result/Instructor/Course_Detail_Tabs_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/05c8d6b6bda96733768c9040919a1f2a5e01baff.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/05c8d6b6bda96733768c9040919a1f2a5e01baff.png new file mode 100644 index 00000000..00428998 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/05c8d6b6bda96733768c9040919a1f2a5e01baff.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/124aa5c1e2968710d7151e06d900bb12f41eb2f3.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/124aa5c1e2968710d7151e06d900bb12f41eb2f3.png new file mode 100644 index 00000000..15095bd7 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/124aa5c1e2968710d7151e06d900bb12f41eb2f3.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3d172ca5f26f33c946c982ccccb69b895adabc25.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3d172ca5f26f33c946c982ccccb69b895adabc25.png new file mode 100644 index 00000000..dca353d5 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3d172ca5f26f33c946c982ccccb69b895adabc25.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3e76fc70092c9da0bdce1f5f6416f09fa381c305.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3e76fc70092c9da0bdce1f5f6416f09fa381c305.png new file mode 100644 index 00000000..08290a9d Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/3e76fc70092c9da0bdce1f5f6416f09fa381c305.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/59409841bc0a58fee9b289ae43471f82aeadced6.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/59409841bc0a58fee9b289ae43471f82aeadced6.png new file mode 100644 index 00000000..d675aca2 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/59409841bc0a58fee9b289ae43471f82aeadced6.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/5ab116131594ffd0289eefd700d6491505e1c71a.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/5ab116131594ffd0289eefd700d6491505e1c71a.png new file mode 100644 index 00000000..822bd62f Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/5ab116131594ffd0289eefd700d6491505e1c71a.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ceb066997ecc48380fe55519c2c5201661b92d65.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ceb066997ecc48380fe55519c2c5201661b92d65.png new file mode 100644 index 00000000..1bb7ec85 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ceb066997ecc48380fe55519c2c5201661b92d65.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/e7fd2a77f7879d1bda44afce77a4e1b085add249.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/e7fd2a77f7879d1bda44afce77a4e1b085add249.png new file mode 100644 index 00000000..9b723346 Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/e7fd2a77f7879d1bda44afce77a4e1b085add249.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ebb6e30487eeb33a334792f175969cd79b956af6.png b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ebb6e30487eeb33a334792f175969cd79b956af6.png new file mode 100644 index 00000000..fb7fab7c Binary files /dev/null and b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/data/ebb6e30487eeb33a334792f175969cd79b956af6.png differ diff --git a/frontend_management/test_result/Instructor/Courses_List_Page_Tests/index.html b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/index.html new file mode 100644 index 00000000..a65852b2 --- /dev/null +++ b/frontend_management/test_result/Instructor/Courses_List_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/27f0f34d39ba3afdcddd6292f21b1fb2e1f91347.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/27f0f34d39ba3afdcddd6292f21b1fb2e1f91347.png new file mode 100644 index 00000000..4d640a4f Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/27f0f34d39ba3afdcddd6292f21b1fb2e1f91347.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/2a52458e5d775d7f6a5470a4ebe6dfce88202762.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/2a52458e5d775d7f6a5470a4ebe6dfce88202762.png new file mode 100644 index 00000000..0040e662 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/2a52458e5d775d7f6a5470a4ebe6dfce88202762.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/7957c7870ad1af4b313598a422bf95f9975bd4b3.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/7957c7870ad1af4b313598a422bf95f9975bd4b3.png new file mode 100644 index 00000000..b7325c6a Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/7957c7870ad1af4b313598a422bf95f9975bd4b3.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/aeaead5182882b1c055b71b4263a086d49ae21f9.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/aeaead5182882b1c055b71b4263a086d49ae21f9.png new file mode 100644 index 00000000..bb1671c7 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/aeaead5182882b1c055b71b4263a086d49ae21f9.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/bbf166853a6c394a6ca293b2395842ca32fa59fb.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/bbf166853a6c394a6ca293b2395842ca32fa59fb.png new file mode 100644 index 00000000..b7e3a691 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/bbf166853a6c394a6ca293b2395842ca32fa59fb.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c218c639a925799e58f2c6cd7a6285d1bf3a63da.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c218c639a925799e58f2c6cd7a6285d1bf3a63da.png new file mode 100644 index 00000000..af0ef959 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c218c639a925799e58f2c6cd7a6285d1bf3a63da.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c33d46f246f6417be4064747a2cadbfce753066e.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c33d46f246f6417be4064747a2cadbfce753066e.png new file mode 100644 index 00000000..09edd9c0 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c33d46f246f6417be4064747a2cadbfce753066e.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c7e5afbe64b19d3bca6a5563b2b5fac4f58742ca.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c7e5afbe64b19d3bca6a5563b2b5fac4f58742ca.png new file mode 100644 index 00000000..134e5680 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/c7e5afbe64b19d3bca6a5563b2b5fac4f58742ca.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/f0a098f9935cd85e715e1c92119eb18b3cc23078.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/f0a098f9935cd85e715e1c92119eb18b3cc23078.png new file mode 100644 index 00000000..9dc39bd3 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/f0a098f9935cd85e715e1c92119eb18b3cc23078.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/fa57c0d509931d0977a2cc80bc5da68b6e474c23.png b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/fa57c0d509931d0977a2cc80bc5da68b6e474c23.png new file mode 100644 index 00000000..0a160951 Binary files /dev/null and b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/data/fa57c0d509931d0977a2cc80bc5da68b6e474c23.png differ diff --git a/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/index.html b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/index.html new file mode 100644 index 00000000..76c7bfbe --- /dev/null +++ b/frontend_management/test_result/Instructor/Create_Course_&_Structure_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/153d6902c0ca4fef1aa2fc65120adc1d08048286.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/153d6902c0ca4fef1aa2fc65120adc1d08048286.png new file mode 100644 index 00000000..159eea6f Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/153d6902c0ca4fef1aa2fc65120adc1d08048286.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/452ffe0882b032c2d3bab8fe7fe4b31907aa7ec5.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/452ffe0882b032c2d3bab8fe7fe4b31907aa7ec5.png new file mode 100644 index 00000000..ec57a13d Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/452ffe0882b032c2d3bab8fe7fe4b31907aa7ec5.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/4e2f57b76392ddca8cf0d10da5302f65a7045e4f.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/4e2f57b76392ddca8cf0d10da5302f65a7045e4f.png new file mode 100644 index 00000000..bcc7f90b Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/4e2f57b76392ddca8cf0d10da5302f65a7045e4f.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/85b3be3b178a6a2e49e992a3f9d0c366b9474fbc.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/85b3be3b178a6a2e49e992a3f9d0c366b9474fbc.png new file mode 100644 index 00000000..cf757634 Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/85b3be3b178a6a2e49e992a3f9d0c366b9474fbc.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/c774e2d38891f831ce99cc3f497ad1b1090077de.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/c774e2d38891f831ce99cc3f497ad1b1090077de.png new file mode 100644 index 00000000..fae095d1 Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/c774e2d38891f831ce99cc3f497ad1b1090077de.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/data/ccb7f4bf19a7a5908adc27d250a40618847c1cf2.png b/frontend_management/test_result/Instructor/Dashboard_Tests/data/ccb7f4bf19a7a5908adc27d250a40618847c1cf2.png new file mode 100644 index 00000000..92ba35d6 Binary files /dev/null and b/frontend_management/test_result/Instructor/Dashboard_Tests/data/ccb7f4bf19a7a5908adc27d250a40618847c1cf2.png differ diff --git a/frontend_management/test_result/Instructor/Dashboard_Tests/index.html b/frontend_management/test_result/Instructor/Dashboard_Tests/index.html new file mode 100644 index 00000000..1a5b4749 --- /dev/null +++ b/frontend_management/test_result/Instructor/Dashboard_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/16708d259129df93cdd9a5cd6260b44467f430e0.png b/frontend_management/test_result/auth/Login_Page_Tests/data/16708d259129df93cdd9a5cd6260b44467f430e0.png new file mode 100644 index 00000000..fd6160b2 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/16708d259129df93cdd9a5cd6260b44467f430e0.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/631c2ea22b6adeaed9641041947a76d74edc18b8.png b/frontend_management/test_result/auth/Login_Page_Tests/data/631c2ea22b6adeaed9641041947a76d74edc18b8.png new file mode 100644 index 00000000..e846ed00 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/631c2ea22b6adeaed9641041947a76d74edc18b8.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/65f8acf6a09faed4c14ed6a633732862db853d36.png b/frontend_management/test_result/auth/Login_Page_Tests/data/65f8acf6a09faed4c14ed6a633732862db853d36.png new file mode 100644 index 00000000..ee5d20ce Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/65f8acf6a09faed4c14ed6a633732862db853d36.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/7e79c41c8858b523f5f00ca0d96ee66e8a87263c.png b/frontend_management/test_result/auth/Login_Page_Tests/data/7e79c41c8858b523f5f00ca0d96ee66e8a87263c.png new file mode 100644 index 00000000..fd586dab Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/7e79c41c8858b523f5f00ca0d96ee66e8a87263c.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/84ee2be597a0472f1f34fba60dabc39a3397acc0.png b/frontend_management/test_result/auth/Login_Page_Tests/data/84ee2be597a0472f1f34fba60dabc39a3397acc0.png new file mode 100644 index 00000000..4bcb1fd7 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/84ee2be597a0472f1f34fba60dabc39a3397acc0.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/a9f2781e64d249e5dcb8ae23681d82b7e0306f11.png b/frontend_management/test_result/auth/Login_Page_Tests/data/a9f2781e64d249e5dcb8ae23681d82b7e0306f11.png new file mode 100644 index 00000000..04846e58 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/a9f2781e64d249e5dcb8ae23681d82b7e0306f11.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/bc9a2a90835ca972045b4e2fd9eedab17f6e1cab.png b/frontend_management/test_result/auth/Login_Page_Tests/data/bc9a2a90835ca972045b4e2fd9eedab17f6e1cab.png new file mode 100644 index 00000000..22656a62 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/bc9a2a90835ca972045b4e2fd9eedab17f6e1cab.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/dca2a979a4f710cebde1893e6fa7a420bbce07ca.png b/frontend_management/test_result/auth/Login_Page_Tests/data/dca2a979a4f710cebde1893e6fa7a420bbce07ca.png new file mode 100644 index 00000000..514da961 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/dca2a979a4f710cebde1893e6fa7a420bbce07ca.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/data/e391cbda97e779364b7c6ab4a9c78a999b4aeef1.png b/frontend_management/test_result/auth/Login_Page_Tests/data/e391cbda97e779364b7c6ab4a9c78a999b4aeef1.png new file mode 100644 index 00000000..1374e6c7 Binary files /dev/null and b/frontend_management/test_result/auth/Login_Page_Tests/data/e391cbda97e779364b7c6ab4a9c78a999b4aeef1.png differ diff --git a/frontend_management/test_result/auth/Login_Page_Tests/index.html b/frontend_management/test_result/auth/Login_Page_Tests/index.html new file mode 100644 index 00000000..982af847 --- /dev/null +++ b/frontend_management/test_result/auth/Login_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/0809b14cfe86a6650b5c9ccaea6ab6396a7c6882.png b/frontend_management/test_result/auth/Register_Page_Tests/data/0809b14cfe86a6650b5c9ccaea6ab6396a7c6882.png new file mode 100644 index 00000000..0c642e08 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/0809b14cfe86a6650b5c9ccaea6ab6396a7c6882.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/34608ae454cb4fdde9edcfee7bc324ff6e9ac00a.png b/frontend_management/test_result/auth/Register_Page_Tests/data/34608ae454cb4fdde9edcfee7bc324ff6e9ac00a.png new file mode 100644 index 00000000..2cd4e3bf Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/34608ae454cb4fdde9edcfee7bc324ff6e9ac00a.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/34a7199dc793b5cc6db9a6e2ab253bbb974a12cd.png b/frontend_management/test_result/auth/Register_Page_Tests/data/34a7199dc793b5cc6db9a6e2ab253bbb974a12cd.png new file mode 100644 index 00000000..203a8cb1 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/34a7199dc793b5cc6db9a6e2ab253bbb974a12cd.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/7d0da5fa1e26962ec6c30ce5825cb0d1385cf2cd.png b/frontend_management/test_result/auth/Register_Page_Tests/data/7d0da5fa1e26962ec6c30ce5825cb0d1385cf2cd.png new file mode 100644 index 00000000..ab38c3c8 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/7d0da5fa1e26962ec6c30ce5825cb0d1385cf2cd.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/9efa2fbb70373a1485233cf195e488e69133bac6.png b/frontend_management/test_result/auth/Register_Page_Tests/data/9efa2fbb70373a1485233cf195e488e69133bac6.png new file mode 100644 index 00000000..46ba6ab9 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/9efa2fbb70373a1485233cf195e488e69133bac6.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/bd755655c173a8648e7d6d3c5b2f02a0946947fb.png b/frontend_management/test_result/auth/Register_Page_Tests/data/bd755655c173a8648e7d6d3c5b2f02a0946947fb.png new file mode 100644 index 00000000..2b55e310 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/bd755655c173a8648e7d6d3c5b2f02a0946947fb.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/c95315ba929130c12a0cbd5bf9cb5d7f1cb08f5e.png b/frontend_management/test_result/auth/Register_Page_Tests/data/c95315ba929130c12a0cbd5bf9cb5d7f1cb08f5e.png new file mode 100644 index 00000000..619a5fc0 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/c95315ba929130c12a0cbd5bf9cb5d7f1cb08f5e.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/data/efbfcc1bab4f46872473695bb589c80be175f811.png b/frontend_management/test_result/auth/Register_Page_Tests/data/efbfcc1bab4f46872473695bb589c80be175f811.png new file mode 100644 index 00000000..1d703012 Binary files /dev/null and b/frontend_management/test_result/auth/Register_Page_Tests/data/efbfcc1bab4f46872473695bb589c80be175f811.png differ diff --git a/frontend_management/test_result/auth/Register_Page_Tests/index.html b/frontend_management/test_result/auth/Register_Page_Tests/index.html new file mode 100644 index 00000000..1499aee9 --- /dev/null +++ b/frontend_management/test_result/auth/Register_Page_Tests/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend_management/tests/admin/audit-log.spec.ts b/frontend_management/tests/admin/audit-log.spec.ts new file mode 100644 index 00000000..d71ab27a --- /dev/null +++ b/frontend_management/tests/admin/audit-log.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Audit Log Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Audit Log', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminAuditLog); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('Audit Logs', { exact: true })).toBeVisible(); + await expect(page.getByText('āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāđƒāļŠāđ‰āļ‡āļēāļ™āļĢāļ°āļšāļšāđāļĨāļ°āļāļīāļˆāļāļĢāļĢāļĄāļ•āđˆāļēāļ‡āđ†')).toBeVisible(); + + // Stats cards + await expect(page.getByText('Logs āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + await expect(page.getByText('Logs āļ§āļąāļ™āļ™āļĩāđ‰')).toBeVisible(); + + await expect(page.getByRole('button', { name: 'āļĢāļĩāđ€āļŸāļĢāļŠ' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'āļĨāđ‰āļēāļ‡āļ›āļĢāļ°āļ§āļąāļ•āļīāđ€āļāđˆāļē' })).toBeVisible(); + }); + + test('should refresh data when clicking refresh button', async ({ page }) => { + const refreshBtn = page.getByRole('button', { name: 'āļĢāļĩāđ€āļŸāļĢāļŠ' }); + await refreshBtn.click(); + await page.waitForLoadState('networkidle'); + + // Table should still be visible after refresh + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter by Action', async ({ page }) => { + // Click the Action select + const actionSelect = page.locator('label').filter({ hasText: 'Action' }); + await actionSelect.click(); + await page.waitForTimeout(300); + + // Select an option from dropdown + const option = page.locator('.q-item__label').filter({ hasText: 'LOGIN' }).first(); + await option.click(); + + // Click search + await page.getByRole('button', { name: 'āļ„āđ‰āļ™āļŦāļē' }).click(); + await page.waitForLoadState('networkidle'); + + // Table should be visible + await expect(page.locator('.q-table')).toBeVisible(); + + await page.getByRole('button', { name: 'āļĨāđ‰āļēāļ‡āļ•āļąāļ§āļāļĢāļ­āļ‡' }).click(); + await page.waitForLoadState('networkidle'); + + // Entity type input should be cleared + await expect(option).not.toBeVisible(); + }); + + test('check open details dialog', async ({ page }) => { + // Wait for table data to load + const viewBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await viewBtn.isVisible().catch(() => false); + + if (hasData) { + await viewBtn.click(); + await page.waitForTimeout(300); + + // Details dialog should be visible + await expect(page.getByText('āļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ” Audit Log')).toBeVisible(); + + // Close button + await page.getByRole('button', { name: 'āļ›āļīāļ”' }).click(); + } + }); + + test('check close cleanup dialog on cancel', async ({ page }) => { + await page.getByRole('button', { name: 'āļĨāđ‰āļēāļ‡āļ›āļĢāļ°āļ§āļąāļ•āļīāđ€āļāđˆāļē' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.q-dialog')).toBeVisible(); + + // Click cancel + await page.getByRole('button', { name: 'āļĒāļāđ€āļĨāļīāļ' }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); + + test('check close cleanup dialog on confirm', async ({ page }) => { + await page.getByRole('button', { name: 'āļĨāđ‰āļēāļ‡āļ›āļĢāļ°āļ§āļąāļ•āļīāđ€āļāđˆāļē' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.q-dialog')).toBeVisible(); + + // Click confirm + await page.getByRole('button', { name: 'āļĨāļšāļ‚āđ‰āļ­āļĄāļđāļĨ', exact: true }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/categories.spec.ts b/frontend_management/tests/admin/categories.spec.ts new file mode 100644 index 00000000..bc5da7c7 --- /dev/null +++ b/frontend_management/tests/admin/categories.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker } from '@faker-js/faker'; + +/** + * Admin Categories Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Categories', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminCategories); + await page.waitForLoadState('networkidle'); + }); + + test('check display page title and table', async ({ page }) => { + await expect(page.getByText('āļĢāļēāļĒāļāļēāļĢāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ')).toBeVisible(); + await expect(page.locator('.q-table')).toBeVisible(); + await expect(page.getByText('āļŠāļ·āđˆāļ­āļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ')).toBeVisible(); + await expect(page.getByText('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ')).toBeVisible(); + await expect(page.getByText('āļ§āļąāļ™āļ—āļĩāđˆāļŠāļĢāđ‰āļēāļ‡')).toBeVisible(); + await expect(page.getByText('āļāļēāļĢāļˆāļąāļ”āļāļēāļĢ')).toBeVisible(); + }); + + + test('check search input', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ"]'); + await expect(searchInput).toBeVisible(); + await searchInput.fill('āļ˜āļļāļĢāļāļīāļˆ'); + await page.waitForTimeout(500); + await expect(page.getByText('āļ˜āļļāļĢāļāļīāļˆ', { exact: true })).toBeVisible(); + }); + + test('check filter categories by search', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ"]'); + await searchInput.fill('āđ„āļĄāđˆāļĄāļĩāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāļ™āļĩāđ‰āđāļ™āđˆāļ™āļ­āļ™'); + await page.waitForTimeout(500); + + // āļ•āļēāļĢāļēāļ‡āļ„āļ§āļĢāđ„āļĄāđˆāļĄāļĩāļ‚āđ‰āļ­āļĄāļđāļĨ + await expect(page.getByText('No data available')).toBeVisible(); + }); + + + test('should add new category successfully', async ({ page }) => { + // āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨāļŠāļļāđˆāļĄāļ”āđ‰āļ§āļĒ faker + const nameTh = faker.word.noun(); + const nameEn = faker.commerce.department(); + const slug = faker.helpers.slugify(nameEn.toLowerCase()) + '-' + faker.string.alpha({ length: 6, casing: 'lower' }); + const descTh = faker.lorem.sentence(); + const descEn = faker.lorem.sentence(); + + // 1. āļāļ”āļ›āļļāđˆāļĄāđ€āļžāļīāđˆāļĄāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ + await page.getByRole('button', { name: /āđ€āļžāļīāđˆāļĄāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāđƒāļŦāļĄāđˆ/ }).click(); + await expect(page.getByText('āđ€āļžāļīāđˆāļĄ', { exact: true })).toBeVisible(); + + // 2. āļāļĢāļ­āļāļ‚āđ‰āļ­āļĄāļđāļĨāđƒāļ™āļŸāļ­āļĢāđŒāļĄ + const dialog = page.locator('.q-dialog'); + const inputs = dialog.locator('input'); + await inputs.nth(0).fill(nameTh); + await inputs.nth(1).fill(nameEn); + await inputs.nth(2).fill(slug); + + const textareas = dialog.locator('textarea'); + await textareas.nth(0).fill(descTh); + await textareas.nth(1).fill(descEn); + + // 3. āļāļ”āļ›āļļāđˆāļĄ "āđ€āļžāļīāđˆāļĄ" + await page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄ', exact: true }).click(); + + // 4. āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩ notification āļŠāļģāđ€āļĢāđ‡āļˆ + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + + // 5. modal āļ•āđ‰āļ­āļ‡āļ›āļīāļ” + await expect(dialog).not.toBeVisible(); + + // 6. āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāđƒāļŦāļĄāđˆāđāļŠāļ”āļ‡āđƒāļ™āļ•āļēāļĢāļēāļ‡ + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ"]'); + await searchInput.fill(nameTh); + await page.waitForTimeout(500); + await expect(page.getByText(nameTh, { exact: true })).toBeVisible(); + + const editBtn = page.locator('.q-table button .q-icon').filter({ hasText: 'edit' }).first(); + const deleteBtn = page.locator('.q-table button .q-icon').filter({ hasText: 'delete' }).first(); + await expect(editBtn).toBeVisible(); + await expect(deleteBtn).toBeVisible(); + + await editBtn.click(); + await page.waitForTimeout(500); + await expect(page.getByText('āđāļāđ‰āđ„āļ‚āļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ', { exact: true })).toBeVisible(); + await inputs.nth(0).fill(nameTh + ' edit'); + await inputs.nth(1).fill(nameEn + ' edit'); + await inputs.nth(2).fill(slug + 'edit'); + await textareas.nth(0).fill(descTh + ' edit'); + await textareas.nth(1).fill(descEn + ' edit'); + await page.getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ', exact: true }).click(); + await page.waitForTimeout(500); + + await searchInput.fill(nameTh + ' edit'); + await page.waitForTimeout(500); + await expect(page.getByText(nameTh + ' edit', { exact: true })).toBeVisible(); + + await deleteBtn.click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'āļĨāļšāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ' }).click(); + await page.waitForTimeout(500); + await expect(page.locator('.q-notification').filter({ hasText: 'deleted' })).toBeVisible({ timeout: 10_000 }); + }); + + test('should show validation error when submitting empty form', async ({ page }) => { + // 1. āđ€āļ›āļīāļ” modal āđ€āļžāļīāđˆāļĄāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ + await page.getByRole('button', { name: /āđ€āļžāļīāđˆāļĄāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāđƒāļŦāļĄāđˆ/ }).click(); + await expect(page.getByText('āđ€āļžāļīāđˆāļĄ', { exact: true })).toBeVisible(); + + // 2. āļāļ”āļ›āļļāđˆāļĄ "āđ€āļžāļīāđˆāļĄ" āđ‚āļ”āļĒāđ„āļĄāđˆāļāļĢāļ­āļāļ‚āđ‰āļ­āļĄāļđāļĨ + await page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄ', exact: true }).click(); + + // 3. āļ•āļĢāļ§āļˆāļŠāļ­āļš validation error + await expect(page.getByText('āļāļĢāļļāļ“āļēāļāļĢāļ­āļāļŠāļ·āđˆāļ­')).toBeVisible(); + }); + + test('should close add modal on cancel', async ({ page }) => { + // 1. āđ€āļ›āļīāļ” modal + await page.getByRole('button', { name: /āđ€āļžāļīāđˆāļĄāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆāđƒāļŦāļĄāđˆ/ }).click(); + await expect(page.getByText('āđ€āļžāļīāđˆāļĄ', { exact: true })).toBeVisible(); + + // 2. āļāļ”āļĒāļāđ€āļĨāļīāļ + await page.getByRole('button', { name: 'āļĒāļāđ€āļĨāļīāļ' }).click(); + + // 3. modal āļ•āđ‰āļ­āļ‡āļŦāļēāļĒāđ„āļ› + await expect(page.locator('.q-dialog')).not.toBeVisible(); + }); + + test('should open edit modal when clicking edit button', async ({ page }) => { + // 1. āļāļ”āļ›āļļāđˆāļĄ edit āļ‚āļ­āļ‡āđāļ–āļ§āđāļĢāļ + const editBtn = page.locator('.q-table button[class*="q-btn"]').first(); + await expect(editBtn).toBeVisible(); + await editBtn.click(); + + // 2. āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē modal āđāļāđ‰āđ„āļ‚āđ€āļ›āļīāļ”āļ‚āļķāđ‰āļ™ + await expect(page.getByText('āđāļāđ‰āđ„āļ‚āļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ')).toBeVisible(); + + // 3. āļŸāļ­āļĢāđŒāļĄāļ•āđ‰āļ­āļ‡āļĄāļĩāļ‚āđ‰āļ­āļĄāļđāļĨāļ­āļĒāļđāđˆāđāļĨāđ‰āļ§ (āđ„āļĄāđˆāļ§āđˆāļēāļ‡) + const nameInput = page.locator('.q-dialog input').first(); + await expect(nameInput).not.toHaveValue(''); + + // 4. āļ›āļļāđˆāļĄāļ•āđ‰āļ­āļ‡āđ€āļ›āđ‡āļ™ "āļšāļąāļ™āļ—āļķāļ" āđ„āļĄāđˆāđƒāļŠāđˆ "āđ€āļžāļīāđˆāļĄ" + await expect(page.getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' })).toBeVisible(); + }); + + test('should show delete confirmation dialog', async ({ page }) => { + // 1. āļāļ”āļ›āļļāđˆāļĄ delete āļ‚āļ­āļ‡āđāļ–āļ§āđāļĢāļ (āļ›āļļāđˆāļĄāļ—āļĩāđˆāļŠāļ­āļ‡āđƒāļ™ actions) + const deleteBtn = page.locator('.q-table .flex.gap-2 button').nth(1); + await expect(deleteBtn).toBeVisible(); + await deleteBtn.click(); + + // 2. āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē dialog āļĒāļ·āļ™āļĒāļąāļ™āļāļēāļĢāļĨāļšāđāļŠāļ”āļ‡ + await expect(page.getByText('āļĒāļ·āļ™āļĒāļąāļ™āļāļēāļĢāļĨāļš')).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/courses-pending.spec.ts b/frontend_management/tests/admin/courses-pending.spec.ts new file mode 100644 index 00000000..e792a75c --- /dev/null +++ b/frontend_management/tests/admin/courses-pending.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Pending Courses Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Pending Courses', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminPendingCourses); + await page.waitForLoadState('networkidle'); + }); + + test('should display stats cards', async ({ page }) => { + await expect(page.getByText('āļĢāļ­āļ•āļĢāļ§āļˆāļŠāļ­āļš')).toBeVisible(); + await expect(page.getByText('āļšāļ—āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + await expect(page.getByText('āļšāļ—āđ€āļĢāļĩāļĒāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + }); + + test('should search courses by keyword "āļžāļ·āđ‰āļ™āļāļēāļ™"', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļē"]'); + await expect(searchInput).toBeVisible(); + + // āļ„āđ‰āļ™āļŦāļēāļ„āļģāļ§āđˆāļē "āļžāļ·āđ‰āļ™āļāļēāļ™" + await searchInput.fill('āļžāļ·āđ‰āļ™āļāļēāļ™'); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāđāļŠāļ”āļ‡āļœāļĨāļĨāļąāļžāļ˜āđŒāļ—āļĩāđˆāļ•āļĢāļ‡āļāļąāļ™ āļŦāļĢāļ·āļ­āđāļŠāļ”āļ‡ empty state + const emptyState = page.getByText('āđ„āļĄāđˆāļĄāļĩāļ„āļ­āļĢāđŒāļŠāļ—āļĩāđˆāļĢāļ­āļāļēāļĢāļ­āļ™āļļāļĄāļąāļ•āļī'); + const isEmpty = await emptyState.isVisible().catch(() => false); + + if (isEmpty) { + await expect(emptyState).toBeVisible(); + } else { + // āļ–āđ‰āļēāļĄāļĩāļœāļĨāļĨāļąāļžāļ˜āđŒ āļŠāļ·āđˆāļ­āļ„āļ­āļĢāđŒāļŠāļ•āđ‰āļ­āļ‡āļĄāļĩāļ„āļģāļ§āđˆāļē "āļžāļ·āđ‰āļ™āļāļēāļ™" + const courseCards = page.locator('.bg-white.rounded-xl.shadow-sm.overflow-hidden'); + const count = await courseCards.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + await expect(courseCards.nth(i)).toContainText('āļžāļ·āđ‰āļ™āļāļēāļ™'); + } + } + }); + + test('should filter courses by search text', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļē"]'); + await searchInput.fill('nonexistent-course-xyz'); + + // Should show empty state or filtered results + // Wait briefly for filter to apply + await page.waitForTimeout(500); + + // Either shows "āđ„āļĄāđˆāļĄāļĩāļ„āļ­āļĢāđŒāļŠāļ—āļĩāđˆāļĢāļ­āļāļēāļĢāļ­āļ™āļļāļĄāļąāļ•āļī" or filtered results + const emptyState = page.getByText('āđ„āļĄāđˆāļĄāļĩāļ„āļ­āļĢāđŒāļŠāļ—āļĩāđˆāļĢāļ­āļāļēāļĢāļ­āļ™āļļāļĄāļąāļ•āļī'); + const courseCards = page.locator('.bg-white.rounded-xl.shadow-sm.overflow-hidden'); + + // One of these should be visible + const isEmpty = await emptyState.isVisible().catch(() => false); + if (isEmpty) { + await expect(emptyState).toBeVisible(); + } + }); + + test('should toggle between card and table view', async ({ page }) => { + // Find the view toggle buttons + const toggleGroup = page.locator('.q-btn-toggle'); + await expect(toggleGroup).toBeVisible(); + + // Click table view + await page.locator('.q-btn-toggle button').last().click(); + await page.waitForTimeout(300); + + // Table should be visible + await expect(page.locator('.q-table')).toBeVisible(); + + // Switch back to card view + await page.locator('.q-btn-toggle button').first().click(); + await page.waitForTimeout(300); + + await page.locator('.q-btn-toggle button').last().click(); + await page.waitForTimeout(300); + + // Table should have expected columns + await expect(page.getByText('āļŠāļ·āđˆāļ­āļ„āļ­āļĢāđŒāļŠ')).toBeVisible(); + await expect(page.getByText('āļœāļđāđ‰āļŠāļ­āļ™')).toBeVisible(); + await expect(page.getByText('āļ§āļąāļ™āļ—āļĩāđˆāļŠāđˆāļ‡')).toBeVisible(); + }); + + test('should have refresh button', async ({ page }) => { + const refreshBtn = page.getByRole('button', { name: /āļĢāļĩāđ€āļŸāļĢāļŠ/ }); + await expect(refreshBtn).toBeVisible(); + + // Click refresh + await refreshBtn.click(); + + // Should trigger loading (spinner appears briefly) + await page.waitForLoadState('networkidle'); + }); + + test('should navigate to course detail on click if ', async ({ page }) => { + // Check if there are any courses + const courseExists = await page.locator('.bg-white.rounded-xl').first().isVisible().catch(() => false); + + if (courseExists) { + // Click "āļ”āļđāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”" button on first course + const viewBtn = page.locator('.q-table button[class*="q-btn"]').first(); + if (await viewBtn.isVisible()) { + await viewBtn.click(); + await page.waitForURL('**/admin/courses/**'); + await expect(page).toHaveURL(/\/admin\/courses\/\d+/); + await page.waitForTimeout(1000); + } + } + }); + +}); diff --git a/frontend_management/tests/admin/dashboard.spec.ts b/frontend_management/tests/admin/dashboard.spec.ts new file mode 100644 index 00000000..3d03f833 --- /dev/null +++ b/frontend_management/tests/admin/dashboard.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Dashboard Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminDashboard); + await page.waitForLoadState('networkidle'); + }); + + test('show display dashboard', async ({ page }) => { + await expect(page.locator('h1')).toContainText('āļŠāļ§āļąāļŠāļ”āļĩ'); + await expect(page.locator("//p[normalize-space(text())='āļ„āļ­āļĢāđŒāļŠāļĢāļ­āļ­āļ™āļļāļĄāļąāļ•āļī']")).toBeVisible(); + await expect(page.getByText('āļāļīāļˆāļāļĢāļĢāļĄāļ§āļąāļ™āļ™āļĩāđ‰')).toBeVisible(); + await expect(page.getByText('āļœāļđāđ‰āđƒāļŠāđ‰āļ‡āļēāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + }); + + test('navigate to pending courses and audit-log page', async ({ page }) => { + await page.locator('a[href*="pending"]', { hasText: 'āļ”āļđāļ—āļąāđ‰āļ‡āļŦāļĄāļ”' }).click(); + await page.waitForURL('**/admin/courses/pending**'); + await expect(page).toHaveURL(/\/admin\/courses\/pending/); + await page.waitForTimeout(500); + + await page.goto(TEST_URLS.adminDashboard); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + await page.locator('a[href*="audit-log"]', { hasText: 'āļ”āļđāļ—āļąāđ‰āļ‡āļŦāļĄāļ”' }).click(); + await page.waitForURL('**/admin/audit-log**'); + await expect(page).toHaveURL(/\/admin\/audit-log/); + await page.waitForTimeout(500); + }); + + test('show user menu on avatar click', async ({ page }) => { + // Click the avatar / user icon area + await page.locator('.w-12.h-12.rounded-full').click(); + await page.waitForTimeout(500); + // Menu should appear with profile and logout options + await expect(page.getByText('āđ‚āļ›āļĢāđ„āļŸāļĨāđŒ')).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/admin/recommended-courses.spec.ts b/frontend_management/tests/admin/recommended-courses.spec.ts new file mode 100644 index 00000000..5fe274f1 --- /dev/null +++ b/frontend_management/tests/admin/recommended-courses.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Recommended Courses Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Recommended Courses', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminRecommendedCourses); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('āļˆāļąāļ”āļāļēāļĢāļ„āļ­āļĢāđŒāļŠāđāļ™āļ°āļ™āļģ')).toBeVisible(); + await expect(page.getByText('Recommended Courses Management')).toBeVisible(); + const toggles = page.locator('.q-table .q-toggle'); + const hasData = await toggles.first().isVisible().catch(() => false); + + if (hasData) { + await expect(toggles.first()).toBeVisible(); + } + }); + + test('should toggle recommendation status', async ({ page }) => { + const toggle = page.locator('.q-table .q-toggle').first(); + const hasData = await toggle.isVisible().catch(() => false); + + if (hasData) { + await toggle.click(); + await page.waitForTimeout(500); + + // Should show notification (success or error) + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + + await toggle.click(); + await page.waitForTimeout(500); + + // Should show notification (success or error) + await expect(page.locator('.q-notification').last()).toBeVisible({ timeout: 10_000 }); + } + }); + + test('open course details dialog', async ({ page }) => { + const viewBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await viewBtn.isVisible().catch(() => false); + + if (hasData) { + await viewBtn.click(); + await page.waitForTimeout(500); + + // Dialog should be visible with course details + await expect(page.getByText('āļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”āļ„āļ­āļĢāđŒāļŠ (Course Details)')).toBeVisible(); + + // Detail sections + await expect(page.getByText('āļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ” (Description)')).toBeVisible(); + await expect(page.getByText('āļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ (Category):')).toBeVisible(); + await expect(page.getByText('āļœāļđāđ‰āļŠāļ­āļ™ (Instructors)')).toBeVisible(); + await page.waitForTimeout(1000); + } + }); +}); diff --git a/frontend_management/tests/admin/users.spec.ts b/frontend_management/tests/admin/users.spec.ts new file mode 100644 index 00000000..2b5b63b2 --- /dev/null +++ b/frontend_management/tests/admin/users.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Admin Users Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ admin-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Admin Users', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.adminUsers); + await page.waitForLoadState('networkidle'); + }); + + test('check display page', async ({ page }) => { + // Header + await expect(page.getByText('āļˆāļąāļ”āļāļēāļĢāļœāļđāđ‰āđƒāļŠāđ‰āļ‡āļēāļ™')).toBeVisible(); + + // Search input + await expect(page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŠāļ·āđˆāļ­"]')).toBeVisible(); + + // Stats cards + await expect(page.getByText('āļœāļđāđ‰āđƒāļŠāđ‰āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + await expect(page.getByText('Admin', { exact: true })).toBeVisible(); + await expect(page.getByText('Instructor', { exact: true })).toBeVisible(); + await expect(page.getByText('Student', { exact: true })).toBeVisible(); + + // Table + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter users by search', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŠāļ·āđˆāļ­"]'); + await searchInput.fill('admin'); + await page.waitForTimeout(500); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + }); + + test('should filter users by role', async ({ page }) => { + // āđƒāļŠāđ‰ outlined q-select (āļ•āļąāļ§ filter role āđ„āļĄāđˆāđƒāļŠāđˆ pagination) + const roleSelect = page.locator('.q-select.q-field--outlined'); + await roleSelect.click(); + await page.waitForTimeout(300); + + // Select Instructor + await page.locator('.q-item__label').filter({ hasText: 'Instructor' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + await roleSelect.click(); + await page.waitForTimeout(300); + // Select Student + await page.locator('.q-item__label').filter({ hasText: 'Student' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + await roleSelect.click(); + await page.waitForTimeout(300); + // Select Admin + await page.locator('.q-item__label').filter({ hasText: 'Admin' }).click(); + await page.waitForTimeout(500); + // Table should show filtered results + await expect(page.locator('.q-table')).toBeVisible(); + + // āļāļĨāļąāļšāđ„āļ›āđ€āļĨāļ·āļ­āļ "āļšāļ—āļšāļēāļ—āļ—āļąāđ‰āļ‡āļŦāļĄāļ”" + await roleSelect.click(); + await page.waitForTimeout(300); + await page.locator('.q-item__label').filter({ hasText: 'āļšāļ—āļšāļēāļ—āļ—āļąāđ‰āļ‡āļŦāļĄāļ”' }).click(); + await page.waitForTimeout(500); + }); + + test('should open action menu', async ({ page }) => { + // Click more_vert button on first row + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Menu should show options + await expect(page.getByText('āļ”āļđ', { exact: true })).toBeVisible(); + await expect(page.getByText('āđāļāđ‰ Role', { exact: true })).toBeVisible(); + await expect(page.getByText('āļĨāļš', { exact: true })).toBeVisible(); + } + }); + + test('should open view user dialog', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Click "āļ”āļđ" option + await page.getByText('āļ”āļđ', { exact: true }).click(); + await page.waitForTimeout(300); + + // Dialog should show user details + await expect(page.getByText('āļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”āļœāļđāđ‰āđƒāļŠāđ‰')).toBeVisible(); + + // Close button + await page.getByRole('button', { name: 'āļ›āļīāļ”' }).click(); + await expect(page.locator('.q-dialog')).not.toBeVisible(); + } + }); + + test('open change role dialog', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + if (hasData) { + await actionBtn.click(); + await page.waitForTimeout(300); + + // Click "āđāļāđ‰ Role" + await page.getByText('āđāļāđ‰ Role').click(); + await page.waitForTimeout(300); + + // Change role dialog should appear + await expect(page.getByText('āđ€āļ›āļĨāļĩāđˆāļĒāļ™ Role')).toBeVisible(); + + // Cancel the dialog + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.waitForTimeout(300); + } + }); + + test('change role ', async ({ page }) => { + const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + const hasData = await actionBtn.isVisible().catch(() => false); + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļēāļŠāļ·āđˆāļ­"]'); + await searchInput.fill('lertp'); + await page.waitForTimeout(500); + + // Table should still be visible with filtered results + await expect(page.locator('.q-table')).toBeVisible(); + if (hasData) { + // Click "āđāļāđ‰ Role Student" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('āđāļāđ‰ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('āđ€āļ›āļĨāļĩāđˆāļĒāļ™ Role')).toBeVisible(); + // select role Student + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Student' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + + // Click "āđāļāđ‰ Role Admin" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('āđāļāđ‰ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('āđ€āļ›āļĨāļĩāđˆāļĒāļ™ Role')).toBeVisible(); + // select role Admin + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Admin' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + + + // Click "āđāļāđ‰ Role Instructor" + await actionBtn.click(); + await page.waitForTimeout(300); + await page.getByText('āđāļāđ‰ Role').click(); + await page.waitForTimeout(300); + // Change role dialog should appear + await expect(page.getByText('āđ€āļ›āļĨāļĩāđˆāļĒāļ™ Role')).toBeVisible(); + // select role Instructor + await page.waitForTimeout(300); + await page.locator('.q-dialog .q-radio__label').filter({ hasText: 'Instructor' }).click(); + await page.waitForTimeout(500); + // save + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(300); + } + }); + + // test('should open delete confirmation dialog', async ({ page }) => { + // const actionBtn = page.locator('.q-table .q-btn').filter({ has: page.locator('.q-icon') }).first(); + // const hasData = await actionBtn.isVisible().catch(() => false); + + // if (hasData) { + // await actionBtn.click(); + // await page.waitForTimeout(300); + + // // Click "āļĨāļš" + // await page.getByText('āļĨāļš').click(); + // await page.waitForTimeout(300); + + // // Delete confirmation dialog + // await expect(page.getByText('āļĒāļ·āļ™āļĒāļąāļ™āļāļēāļĢāļĨāļš')).toBeVisible(); + + // // Cancel the dialog + // await page.getByRole('button', { name: 'Cancel' }).click(); + // await page.waitForTimeout(300); + // } + // }); +}); diff --git a/frontend_management/tests/auth/login.spec.ts b/frontend_management/tests/auth/login.spec.ts new file mode 100644 index 00000000..a664f5cc --- /dev/null +++ b/frontend_management/tests/auth/login.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; +import { TEST_ADMIN, TEST_INSTRUCTOR, TEST_URLS } from '../fixtures/test-data'; + +test.describe('Login Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.login); + }); + + test('should display login form', async ({ page }) => { + // Title + await expect(page.locator('h1')).toContainText('E-Learning'); + + // Email & password fields + await expect(page.locator('input[type="email"]')).toBeVisible(); + await expect(page.locator('input[type="password"]')).toBeVisible(); + + // Submit button + await expect(page.locator('button[type="submit"]')).toBeVisible(); + + // Register link + await expect(page.locator('a[href="/register"]')).toBeVisible(); + }); + + test('should show validation errors for empty fields', async ({ page }) => { + // Click submit without filling fields + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Expect validation messages in Thai + await expect(page.getByText('āļāļĢāļļāļ“āļēāļāļĢāļ­āļāļ­āļĩāđ€āļĄāļĨ')).toBeVisible(); + }); + + test('should show validation errors for empty fields password', async ({ page }) => { + // Click submit without filling fields + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill('test@email.com'); + await page.waitForTimeout(1000); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Expect validation messages in Thai + await expect(page.getByText('āļāļĢāļļāļ“āļēāļāļĢāļ­āļāļĢāļŦāļąāļŠāļœāđˆāļēāļ™')).toBeVisible(); + }); + + test('should toggle password visibility', async ({ page }) => { + const passwordInput = page.locator('input[type="password"]'); + + await page.waitForTimeout(1000); + await passwordInput.fill('test123'); + + await page.waitForTimeout(1000); + // Click the visibility toggle icon + await page.locator('//i[normalize-space(text())="visibility"]').click(); + + // Password field should now be text type + await expect(page.locator('input[type="text"]').last()).toHaveValue('test123'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill('wrong@email.com'); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill('wrongpassword'); + await page.locator('button[type="submit"]').click(); + + // Should show error notification (Quasar Notify) + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + }); + + test('should login as admin and redirect to /admin', async ({ page }) => { + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + await page.locator('button[type="submit"]').click(); + + // Should redirect to admin dashboard + await page.waitForURL('**/admin**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/admin/); + }); + + test('should login as instructor and redirect to /instructor', async ({ page }) => { + await page.waitForTimeout(1000); + await page.locator('input[type="email"]').fill(TEST_INSTRUCTOR.email); + await page.waitForTimeout(1000); + await page.locator('input[type="password"]').fill(TEST_INSTRUCTOR.password); + await page.waitForTimeout(1000); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Should redirect to instructor dashboard + await page.waitForURL('**/instructor**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/instructor/); + }); + + test('should open forgot password modal', async ({ page }) => { + await page.waitForTimeout(1000); + await page.getByText('āļĨāļ·āļĄāļĢāļŦāļąāļŠāļœāđˆāļēāļ™?').click(); + await page.waitForTimeout(1000); + + // Modal should be visible + await expect(page.getByText('āļĨāļ·āļĄāļĢāļŦāļąāļŠāļœāđˆāļēāļ™').nth(1)).toBeVisible(); + await expect(page.locator('.q-dialog input[type="email"]')).toBeVisible(); + }); + + test('should remember email after login with remember me checked', async ({ page }) => { + // 1. Fill credentials + await page.waitForTimeout(500); + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.waitForTimeout(500); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + await page.waitForTimeout(500); + // 2. Check "remember me" + await page.getByText('āļˆāļ”āļˆāļģāļ‰āļąāļ™āđ„āļ§āđ‰').click(); + + // 3. Login + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/admin**', { timeout: 15_000 }); + + // 4. Logout - click avatar menu then logout + await page.getByText('āļ­āļ­āļāļˆāļēāļāļĢāļ°āļšāļš').click(); + await page.waitForTimeout(1000); + // 5. Confirm logout dialog + await page.locator("(//button[@type='button'])[2]").click(); + + // 6. Should redirect back to login page + await page.waitForURL('**/login**', { timeout: 15_000 }); + + // 7. Verify the email is still pre-filled + await expect(page.locator('input[type="email"]')).toHaveValue(TEST_ADMIN.email); + }); +}); diff --git a/frontend_management/tests/auth/register.spec.ts b/frontend_management/tests/auth/register.spec.ts new file mode 100644 index 00000000..d696d022 --- /dev/null +++ b/frontend_management/tests/auth/register.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker } from '@faker-js/faker'; + +/** + * Register Page Tests + * Test instructor registration flow + */ +test.describe('Register Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.register); + }); + + test('check display register form', async ({ page }) => { + // Header + await expect(page.getByText('āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™āđ€āļ›āđ‡āļ™āļœāļđāđ‰āļŠāļ­āļ™')).toBeVisible(); + await expect(page.getByText('āļŠāļĢāđ‰āļēāļ‡āļšāļąāļāļŠāļĩāđ€āļžāļ·āđˆāļ­āđ€āļĢāļīāđˆāļĄāļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢ')).toBeVisible(); + + // Submit button + await expect(page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' })).toBeVisible(); + + // Login link + await expect(page.getByText('āļĄāļĩāļšāļąāļāļŠāļĩāļ­āļĒāļđāđˆāđāļĨāđ‰āļ§?')).toBeVisible(); + await expect(page.getByText('āđ€āļ‚āđ‰āļēāļŠāļđāđˆāļĢāļ°āļšāļš')).toBeVisible(); + }); + + test('should show username min length validation', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill('ab'); + await page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' }).click(); + + await expect(page.getByText('āļ­āļĒāđˆāļēāļ‡āļ™āđ‰āļ­āļĒ 4 āļ•āļąāļ§āļ­āļąāļāļĐāļĢ')).toBeVisible(); + }); + + test('should show email format validation', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill('abeee'); + const emailInput = page.locator('label').filter({ hasText: 'āļ­āļĩāđ€āļĄāļĨ' }).locator('input'); + await emailInput.fill('invalid-email'); + await page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' }).click(); + + await expect(page.getByText('āļĢāļđāļ›āđāļšāļšāļ­āļĩāđ€āļĄāļĨāđ„āļĄāđˆāļ–āļđāļāļ•āđ‰āļ­āļ‡')).toBeVisible(); + }); + + test('should show password min length validation', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill('abeee'); + const emailInput = page.locator('label').filter({ hasText: 'āļ­āļĩāđ€āļĄāļĨ' }).locator('input'); + await emailInput.fill('test@example.com'); + const passwordInput = page.getByRole('textbox', { name: 'āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *', exact: true }); + await passwordInput.fill('1234'); + await page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' }).click(); + + await expect(page.getByText('āļ­āļĒāđˆāļēāļ‡āļ™āđ‰āļ­āļĒ 8 āļ•āļąāļ§āļ­āļąāļāļĐāļĢ')).toBeVisible(); + }); + + test('should show password mismatch validation', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill('abeee'); + const emailInput = page.locator('label').filter({ hasText: 'āļ­āļĩāđ€āļĄāļĨ' }).locator('input'); + await emailInput.fill('test@example.com'); + const passwordInput = page.getByRole('textbox', { name: 'āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *', exact: true }); + const confirmInput = page.getByRole('textbox', { name: 'āļĒāļ·āļ™āļĒāļąāļ™āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *', exact: true }); + + await passwordInput.fill('password123'); + await confirmInput.fill('differentpass'); + await page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' }).click(); + + await expect(page.getByText('āļĢāļŦāļąāļŠāļœāđˆāļēāļ™āđ„āļĄāđˆāļ•āļĢāļ‡āļāļąāļ™')).toBeVisible(); + }); + + test('should toggle password visibility', async ({ page }) => { + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill('abeee'); + const emailInput = page.locator('label').filter({ hasText: 'āļ­āļĩāđ€āļĄāļĨ' }).locator('input'); + await emailInput.fill('test@example.com'); + const passwordInput = page.getByRole('textbox', { name: 'āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *', exact: true }); + await passwordInput.fill('test1234'); + + // Click visibility icon + await page.locator('label').filter({ hasText: 'āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *' }).locator('.q-icon').filter({ hasText: 'visibility' }).click(); + + // Both password fields should now be text type + await expect(passwordInput).toHaveAttribute('type', 'text'); + }); + + test('should navigate to login page', async ({ page }) => { + await page.getByText('āđ€āļ‚āđ‰āļēāļŠāļđāđˆāļĢāļ°āļšāļš').click(); + await page.waitForURL('**/login**', { timeout: 10_000 }); + await expect(page).toHaveURL(/\/login/); + }); + + test('should register successfully with valid data', async ({ page }) => { + const username = faker.internet.username().toLowerCase().replace(/[^a-z0-9]/g, '') + faker.string.alpha({ length: 4, casing: 'lower' }); + const email = faker.internet.email().toLowerCase(); + const password = 'Test@1234'; + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const phone = `0${faker.string.numeric(9)}`; + + // Fill form + const usernameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļœāļđāđ‰āđƒāļŠāđ‰ (Username)' }).locator('input'); + await usernameInput.fill(username); + + await page.locator('label').filter({ hasText: 'āļ­āļĩāđ€āļĄāļĨ' }).locator('input').fill(email); + + const passwordInput = page.getByRole('textbox', { name: 'āļĢāļŦāļąāļŠāļœāđˆāļēāļ™ *', exact: true }); + await passwordInput.fill(password); + + const confirmInput = page.locator('label').filter({ hasText: 'āļĒāļ·āļ™āļĒāļąāļ™āļĢāļŦāļąāļŠāļœāđˆāļēāļ™' }).locator('input'); + await confirmInput.fill(password); + + // Select prefix + const prefixSelect = page.locator('label').filter({ hasText: 'āļ„āļģāļ™āļģāļŦāļ™āđ‰āļē' }); + await prefixSelect.click(); + await page.waitForTimeout(300); + await page.locator('.q-item__label').filter({ hasText: 'āļ™āļēāļĒ / Mr.' }).click(); + + // Fill name + const firstNameInput = page.locator('label').filter({ hasText: 'āļŠāļ·āđˆāļ­āļˆāļĢāļīāļ‡' }).locator('input'); + await firstNameInput.fill(firstName); + + const lastNameInput = page.locator('label').filter({ hasText: 'āļ™āļēāļĄāļŠāļāļļāļĨ' }).locator('input'); + await lastNameInput.fill(lastName); + + // Fill phone + const phoneInput = page.locator('label').filter({ hasText: 'āđ€āļšāļ­āļĢāđŒāđ‚āļ—āļĢāļĻāļąāļžāļ—āđŒ' }).locator('input'); + await phoneInput.fill(phone); + + // Submit + await page.getByRole('button', { name: 'āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™' }).click(); + + // Should show success notification and redirect to login + await expect(page.locator('.q-notification')).toBeVisible({ timeout: 10_000 }); + await page.waitForURL('**/login**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/frontend_management/tests/fixtures/admin.setup.ts b/frontend_management/tests/fixtures/admin.setup.ts new file mode 100644 index 00000000..a1f74def --- /dev/null +++ b/frontend_management/tests/fixtures/admin.setup.ts @@ -0,0 +1,30 @@ +import { test as setup, expect } from '@playwright/test'; +import { TEST_ADMIN, TEST_URLS } from './test-data'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ADMIN_AUTH_FILE = path.resolve(__dirname, '../.auth/admin.json'); + +/** + * Admin authentication setup + * Logs in as admin and saves browser state (cookies) for reuse + */ +setup('authenticate as admin', async ({ page }) => { + await page.goto(TEST_URLS.login); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + // Fill login form + await page.locator('input[type="email"]').fill(TEST_ADMIN.email); + await page.locator('input[type="password"]').fill(TEST_ADMIN.password); + + // Submit + await page.locator('button[type="submit"]').click(); + + // Wait for redirect to admin dashboard + await page.waitForURL('**/admin**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/admin/); + + // Save auth state + await page.context().storageState({ path: ADMIN_AUTH_FILE }); +}); diff --git a/frontend_management/tests/fixtures/instructor.setup.ts b/frontend_management/tests/fixtures/instructor.setup.ts new file mode 100644 index 00000000..9a4dfb8f --- /dev/null +++ b/frontend_management/tests/fixtures/instructor.setup.ts @@ -0,0 +1,31 @@ +import { test as setup, expect } from '@playwright/test'; +import { TEST_INSTRUCTOR, TEST_URLS } from './test-data'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const INSTRUCTOR_AUTH_FILE = path.resolve(__dirname, '../.auth/instructor.json'); + +/** + * Instructor authentication setup + * Logs in as instructor and saves browser state (cookies) for reuse + */ +setup('authenticate as instructor', async ({ page }) => { + await page.goto(TEST_URLS.login); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + // Fill login form + await page.locator('input[type="email"]').fill(TEST_INSTRUCTOR.email); + await page.locator('input[type="password"]').fill(TEST_INSTRUCTOR.password); + + // Submit + await page.locator('button[type="submit"]').click(); + + // Wait for redirect to instructor dashboard + await page.waitForURL('**/instructor**', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/instructor/); + + // Save auth state + await page.context().storageState({ path: INSTRUCTOR_AUTH_FILE }); +}); diff --git a/frontend_management/tests/instructor/course-detail-tabs.spec.ts b/frontend_management/tests/instructor/course-detail-tabs.spec.ts new file mode 100644 index 00000000..cac9ed49 --- /dev/null +++ b/frontend_management/tests/instructor/course-detail-tabs.spec.ts @@ -0,0 +1,294 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker, fakerTH } from '@faker-js/faker'; + +/** + * Instructor Course Detail Tabs Tests + * āļ—āļ”āļŠāļ­āļšāļāļēāļĢāđ€āļ‚āđ‰āļēāļ”āļđāđāļ•āđˆāļĨāļ° tab āđƒāļ™āļŦāļ™āđ‰āļēāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”āļŦāļĨāļąāļāļŠāļđāļ•āļĢ + * āđƒāļŠāđ‰ cookies āļˆāļēāļ instructor-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ + +test.describe.serial('Course Detail Tabs', () => { + let courseUrl: string; + + // ── Step 1: āļ„āđ‰āļ™āļŦāļēāđāļĨāļ°āđ€āļ‚āđ‰āļēāļŦāļĨāļąāļāļŠāļđāļ•āļĢ "āļžāļ·āđ‰āļ™āļāļēāļ™ Python āļŠāļģāļŦāļĢāļąāļšāļœāļđāđ‰āđ€āļĢāļīāđˆāļĄāļ•āđ‰āļ™" ── + test('navigate to Python course detail page', async ({ page }) => { + await page.goto(TEST_URLS.instructorCourses); + await page.waitForLoadState('networkidle'); + + // āļ„āđ‰āļ™āļŦāļēāļŦāļĨāļąāļāļŠāļđāļ•āļĢ (debounce 600ms) + await page.getByPlaceholder('āļ„āđ‰āļ™āļŦāļēāļŦāļĨāļąāļāļŠāļđāļ•āļĢ...').fill('āļžāļ·āđ‰āļ™āļāļēāļ™ Python'); + await page.waitForTimeout(1000); + + // āļŦāļē course card āļ—āļĩāđˆāļĄāļĩāļŠāļ·āđˆāļ­āļ•āļĢāļ‡ āđāļĨāđ‰āļ§āļāļ”āļ›āļļāđˆāļĄ visibility (āļ”āļđāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”) + const courseCard = page.locator('.bg-white.rounded-xl').filter({ hasText: 'āļžāļ·āđ‰āļ™āļāļēāļ™ Python āļŠāļģāļŦāļĢāļąāļšāļœāļđāđ‰āđ€āļĢāļīāđˆāļĄāļ•āđ‰āļ™' }).first(); + await expect(courseCard).toBeVisible({ timeout: 10_000 }); + await courseCard.locator('button').filter({ has: page.locator('.q-icon:has-text("visibility")') }).click(); + + // āļĢāļ­āđ€āļ‚āđ‰āļēāļŦāļ™āđ‰āļēāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ” + await page.waitForURL('**/instructor/courses/*', { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + // āđ€āļāđ‡āļš URL + courseUrl = page.url(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš header elements + await expect(page.locator('h1')).toBeVisible(); + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩ tabs āļ„āļĢāļš + await expect(page.getByRole('tab', { name: 'āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'āļœāļđāđ‰āļŠāļ­āļ™' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'āļœāļĨāļāļēāļĢāļ—āļ”āļŠāļ­āļš' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'āļ›āļĢāļ°āļāļēāļĻ' })).toBeVisible(); + }); + + // ── Step 2: tab āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡ (default) ── + test('display structure tab and preview a lesson', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // tab āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āđ€āļ›āđ‡āļ™ default → āļ„āļ§āļĢāđāļŠāļ”āļ‡āļ­āļĒāļđāđˆāđāļĨāđ‰āļ§ + const structureTab = page.getByRole('tab', { name: 'āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡' }); + await expect(structureTab).toBeVisible(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩ chapters āļŦāļĢāļ·āļ­ empty state + await expect(page.getByText('āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āļšāļ—āđ€āļĢāļĩāļĒāļ™')).toBeVisible(); + const hasEmptyState = await page.getByText('āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļšāļ—āđ€āļĢāļĩāļĒāļ™').isVisible().catch(() => false); + const hasChapters = !hasEmptyState && (await page.locator('.q-list.border-t').first().isVisible().catch(() => false)); + + expect(hasChapters || hasEmptyState).toBeTruthy(); + + // āļ–āđ‰āļēāļĄāļĩ chapters → āļāļ”āđ€āļ‚āđ‰āļēāļ”āļđ lesson āđāļĢāļ + if (hasChapters) { + // āļŦāļē lesson āđāļĢāļāđƒāļ™ structure tab āđāļĨāđ‰āļ§āļāļ” + const firstLesson = page.locator('.q-item').filter({ hasText: /Lesson \d/ }).first(); + await expect(firstLesson).toBeVisible(); + await firstLesson.click(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē LessonPreviewDialog āđ€āļ›āļīāļ” + const dialog = page.locator('.q-dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāđāļŠāļ”āļ‡ title āļ‚āļ­āļ‡ lesson + await expect(dialog.locator('.text-h6')).toBeVisible(); + + // āļ›āļīāļ” dialog + await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click(); + await expect(dialog).toBeHidden({ timeout: 3_000 }); + } + }); + + // ── Step 3: tab āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™ â”€â”€ + test('display students tab and view student detail', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ” tab āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™ + await page.getByRole('tab', { name: 'āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™' }).click(); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš: stat cards āļŦāļĢāļ·āļ­ empty state + const hasStats = await page.getByText('āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”').isVisible().catch(() => false); + const hasEmptyState = await page.getByText('āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āđƒāļ™āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ™āļĩāđ‰').isVisible().catch(() => false); + + expect(hasStats || hasEmptyState).toBeTruthy(); + + // āļ–āđ‰āļēāļĄāļĩāļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™ â†’ āļāļ”āđ€āļ‚āđ‰āļēāļ”āļđāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”āļ„āļ™āđāļĢāļ + if (hasStats) { + await expect(page.getByPlaceholder('āļ„āđ‰āļ™āļŦāļēāļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™...')).toBeVisible(); + + // āļāļ”āļ—āļĩāđˆāļ™āļąāļāđ€āļĢāļĩāļĒāļ™āļ„āļ™āđāļĢāļāđƒāļ™āļĢāļēāļĒāļāļēāļĢ + const firstStudent = page.locator('.q-item.cursor-pointer').first(); + await expect(firstStudent).toBeVisible(); + await firstStudent.click(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē detail modal āđ€āļ›āļīāļ” (maximized) + const dialog = page.locator('.q-dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš progress info + await expect(dialog.getByText('āļ„āļ§āļēāļĄāļ„āļ·āļšāļŦāļ™āđ‰āļēāļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible({ timeout: 10_000 }); + + // āļ›āļīāļ” modal + await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click(); + await expect(dialog).toBeHidden({ timeout: 3_000 }); + } + }); + + // ── Step 4: tab āļœāļđāđ‰āļŠāļ­āļ™ â”€â”€ + test('display instructors tab and search to add instructor', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ” tab āļœāļđāđ‰āļŠāļ­āļ™ + await page.getByRole('tab', { name: 'āļœāļđāđ‰āļŠāļ­āļ™' }).click(); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš header + await expect(page.getByText('āļœāļđāđ‰āļŠāļ­āļ™āđƒāļ™āļĢāļēāļĒāļ§āļīāļŠāļē')).toBeVisible(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš: āļĄāļĩāļāļēāļĢāđŒāļ”āļœāļđāđ‰āļŠāļ­āļ™āļ­āļĒāđˆāļēāļ‡āļ™āđ‰āļ­āļĒ 1 āļ„āļ™ āļŦāļĢāļ·āļ­ empty state + const hasInstructors = await page.getByText('āļŦāļąāļ§āļŦāļ™āđ‰āļēāļœāļđāđ‰āļŠāļ­āļ™').isVisible().catch(() => false); + const hasEmptyState = await page.getByText('āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļ‚āđ‰āļ­āļĄāļđāļĨāļœāļđāđ‰āļŠāļ­āļ™').isVisible().catch(() => false); + + expect(hasInstructors || hasEmptyState).toBeTruthy(); + + // āļ–āđ‰āļēāļĄāļĩāļ›āļļāđˆāļĄāđ€āļžāļīāđˆāļĄāļœāļđāđ‰āļŠāļ­āļ™ (āđāļŠāļ”āļ‡āđ€āļ‰āļžāļēāļ° primary instructor) + const addBtn = page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļœāļđāđ‰āļŠāļ­āļ™' }); + const hasAddBtn = await addBtn.isVisible().catch(() => false); + + if (hasAddBtn) { + // āļāļ”āļ›āļļāđˆāļĄāđ€āļžāļīāđˆāļĄāļœāļđāđ‰āļŠāļ­āļ™ + await addBtn.click(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē dialog āđ€āļ›āļīāļ” + const dialog = page.locator('.q-dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog.getByText('āđ€āļžāļīāđˆāļĄāļœāļđāđ‰āļŠāļ­āļ™')).toBeVisible(); + + // āļ„āđ‰āļ™āļŦāļē "lertp" āđƒāļ™ q-select (use-input) + const selectInput = dialog.locator('.q-select input[type="search"]'); + await selectInput.fill('lertp'); + await page.waitForTimeout(1500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩāļœāļĨāļĨāļąāļžāļ˜āđŒ (dropdown options) āļŦāļĢāļ·āļ­ "āđ„āļĄāđˆāļžāļšāļœāļđāđ‰āđƒāļŠāđ‰" + const hasResults = await page.locator('.q-menu .q-item').first().isVisible().catch(() => false); + expect(hasResults).toBeTruthy(); + } + }); + + // ── Step 5: tab āļœāļĨāļāļēāļĢāļ—āļ”āļŠāļ­āļš â”€â”€ + test('display quiz results and view student detail', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ” tab āļœāļĨāļāļēāļĢāļ—āļ”āļŠāļ­āļš + await page.getByRole('tab', { name: 'āļœāļĨāļāļēāļĢāļ—āļ”āļŠāļ­āļš' }).click(); + await page.waitForTimeout(1000); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš: āļĄāļĩ quiz selector āļŦāļĢāļ·āļ­ empty state + const hasQuizSelector = await page.getByText('āđ€āļĨāļ·āļ­āļāđāļšāļšāļ—āļ”āļŠāļ­āļš').isVisible().catch(() => false); + const hasEmptyState = await page.getByText('āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ™āļĩāđ‰āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāđāļšāļšāļ—āļ”āļŠāļ­āļš').isVisible().catch(() => false); + + expect(hasQuizSelector || hasEmptyState).toBeTruthy(); + + // āļ–āđ‰āļēāļĄāļĩāđāļšāļšāļ—āļ”āļŠāļ­āļš â†’ āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ•āļēāļĢāļēāļ‡ + āļāļ”āļ”āļđāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ”āļ™āļąāļāđ€āļĢāļĩāļĒāļ™ + if (hasQuizSelector) { + // āļĢāļ­āļ•āļēāļĢāļēāļ‡āđ‚āļŦāļĨāļ” (auto-select quiz āđāļĢāļ) + await page.waitForTimeout(1500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩ stats cards (āļ„āļ°āđāļ™āļ™āđ€āļ‰āļĨāļĩāđˆāļĒ) + const hasStats = await page.getByText('āļ„āļ°āđāļ™āļ™āđ€āļ‰āļĨāļĩāđˆāļĒ').isVisible().catch(() => false); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĄāļĩ row āđƒāļ™āļ•āļēāļĢāļēāļ‡ + const firstRow = page.locator('.q-tr.cursor-pointer').first(); + const hasStudents = await firstRow.isVisible().catch(() => false); + + if (hasStudents) { + // āļāļ”āļ™āļąāļāđ€āļĢāļĩāļĒāļ™āļ„āļ™āđāļĢāļ + await firstRow.click(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš detail dialog + const dialog = page.locator('.q-dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ‚āđ‰āļ­āļĄāļđāļĨāļ„āļ°āđāļ™āļ™ + await expect(dialog.getByText('āļ„āļ°āđāļ™āļ™āļ—āļĩāđˆāđ„āļ”āđ‰')).toBeVisible({ timeout: 10_000 }); + + // āļ›āļīāļ” dialog + await dialog.locator('button').filter({ has: page.locator('.q-icon:has-text("close")') }).click(); + await expect(dialog).toBeHidden({ timeout: 3_000 }); + } + } + }); + + // ── Step 6: tab āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī ── + test('should display approval history tab content', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ” tab āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī + await page.getByRole('tab', { name: 'āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī' }).click(); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš: āļĄāļĩ timeline āļŦāļĢāļ·āļ­ empty state + const hasTimeline = await page.locator('.q-timeline').isVisible().catch(() => false); + const hasEmptyState = await page.getByText('āđ„āļĄāđˆāļžāļšāļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī').isVisible().catch(() => false); + + expect(hasTimeline || hasEmptyState).toBeTruthy(); + }); + + // ── Step 7: tab āļ›āļĢāļ°āļāļēāļĻ â”€â”€ + test('display announcements tab and create a new announcement', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ” tab āļ›āļĢāļ°āļāļēāļĻ + await page.getByRole('tab', { name: 'āļ›āļĢāļ°āļāļēāļĻ' }).click(); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš header + await expect(page.getByText('āļ›āļĢāļ°āļāļēāļĻ').first()).toBeVisible(); + + // āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨāļ›āļĢāļ°āļāļēāļĻ + const announcementTitle = fakerTH.lorem.sentence({ min: 3, max: 6 }); + const announcementTitleEn = faker.lorem.sentence({ min: 3, max: 6 }); + const announcementContent = fakerTH.lorem.paragraphs(1); + const announcementContentEn = faker.lorem.paragraphs(1); + + // āļāļ”āļ›āļļāđˆāļĄāļŠāļĢāđ‰āļēāļ‡āļ›āļĢāļ°āļāļēāļĻ + const createBtn = page.getByRole('button', { name: 'āļŠāļĢāđ‰āļēāļ‡āļ›āļĢāļ°āļāļēāļĻ' }); + await expect(createBtn).toBeVisible(); + await createBtn.click(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē dialog āđ€āļ›āļīāļ” + const dialog = page.locator('.q-dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog.getByText('āļŠāļĢāđ‰āļēāļ‡āļ›āļĢāļ°āļāļēāļĻāđƒāļŦāļĄāđˆ')).toBeVisible(); + + // āļāļĢāļ­āļāļŦāļąāļ§āļ‚āđ‰āļ­ (āļ āļēāļĐāļēāđ„āļ—āļĒ) + await dialog.getByLabel('āļŦāļąāļ§āļ‚āđ‰āļ­ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(announcementTitle); + + // āļāļĢāļ­āļāļŦāļąāļ§āļ‚āđ‰āļ­ (English) + await dialog.getByLabel('āļŦāļąāļ§āļ‚āđ‰āļ­ (English)').fill(announcementTitleEn); + + // āļāļĢāļ­āļāđ€āļ™āļ·āđ‰āļ­āļŦāļē (āļ āļēāļĐāļēāđ„āļ—āļĒ) + await dialog.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(announcementContent); + + // āļāļĢāļ­āļāđ€āļ™āļ·āđ‰āļ­āļŦāļē (English) + await dialog.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (English)').fill(announcementContentEn); + + // āļāļ”āļŠāļĢāđ‰āļēāļ‡ + await dialog.getByRole('button', { name: 'āļŠāļĢāđ‰āļēāļ‡' }).click(); + + // āļĢāļ­ dialog āļ›āļīāļ” + āļ•āļĢāļ§āļˆāļŠāļ­āļš success + await expect(dialog).toBeHidden({ timeout: 10_000 }); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļ›āļĢāļ°āļāļēāļĻāļ—āļĩāđˆāļŠāļĢāđ‰āļēāļ‡āđāļŠāļ”āļ‡āđƒāļ™āļĢāļēāļĒāļāļēāļĢ + await page.waitForTimeout(500); + await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 5_000 }); + }); + + // ── Step 8: āļ—āļ”āļŠāļ­āļšāļŠāļĨāļąāļš tabs āļ­āļĒāđˆāļēāļ‡āļĢāļ§āļ”āđ€āļĢāđ‡āļ§ â”€â”€ + test('should switch between all tabs without errors', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + const tabs = ['āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡', 'āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™', 'āļœāļđāđ‰āļŠāļ­āļ™', 'āļœāļĨāļāļēāļĢāļ—āļ”āļŠāļ­āļš', 'āļ›āļĢāļ°āļ§āļąāļ•āļīāļāļēāļĢāļ‚āļ­āļ­āļ™āļļāļĄāļąāļ•āļī', 'āļ›āļĢāļ°āļāļēāļĻ']; + + for (const tabName of tabs) { + await page.getByRole('tab', { name: tabName }).click(); + await page.waitForTimeout(300); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē tab active (āđ„āļĄāđˆ crash) + const tabPanel = page.locator('.q-tab-panel:visible'); + await expect(tabPanel).toBeVisible({ timeout: 5_000 }); + } + + // āļŠāļĨāļąāļšāļāļĨāļąāļšāđ„āļ›āļ—āļĩāđˆ tab āđāļĢāļ + await page.getByRole('tab', { name: 'āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡' }).click(); + await page.waitForTimeout(300); + await expect(page.locator('.q-tab-panel:visible')).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/instructor/courses-list.spec.ts b/frontend_management/tests/instructor/courses-list.spec.ts new file mode 100644 index 00000000..453654c9 --- /dev/null +++ b/frontend_management/tests/instructor/courses-list.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Instructor Courses List Page Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ instructor-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Instructor Courses List', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.instructorCourses); + await page.waitForLoadState('networkidle'); + }); + + test('should display page header', async ({ page }) => { + await expect(page.getByText('āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ‚āļ­āļ‡āļ‰āļąāļ™')).toBeVisible(); + await expect(page.getByText('āļˆāļąāļ”āļāļēāļĢāļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ—āļĩāđˆāļ„āļļāļ“āļŠāļĢāđ‰āļēāļ‡')).toBeVisible(); + const createBtn = page.getByRole('button', { name: /āļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢāđƒāļŦāļĄāđˆ/ }); + await expect(createBtn).toBeVisible(); + }); + + + test('should navigate to create course page', async ({ page }) => { + await page.getByRole('button', { name: /āļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢāđƒāļŦāļĄāđˆ/ }).click(); + await page.waitForURL('**/instructor/courses/create**'); + await expect(page).toHaveURL(/\/instructor\/courses\/create/); + }); + + test('should have search input', async ({ page }) => { + const searchInput = page.locator('input[placeholder*="āļ„āđ‰āļ™āļŦāļē"]'); + await expect(searchInput).toBeVisible(); + await searchInput.fill('JavaScript'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + await expect(page.getByText('āļžāļ·āđ‰āļ™āļāļēāļ™ JavaScript', { exact: true })).toBeVisible(); + }); + + test('should toggle between card and table view', async ({ page }) => { + // Switch to table view + await page.locator('.q-btn-toggle button').last().click(); + await page.waitForTimeout(300); + + // Table should appear + await expect(page.locator('.q-table')).toBeVisible(); + + // Table should have expected columns + await expect(page.locator('thead').getByText('āļŠāļ–āļēāļ™āļ°')).toBeVisible(); + await expect(page.locator('thead').getByText('āļĢāļēāļ„āļē')).toBeVisible(); + await page.waitForTimeout(1000); + + // Switch back to card view + await page.locator('.q-btn-toggle button').first().click(); + await page.waitForTimeout(300); + }); + + test('should show course cards with status badges', async ({ page }) => { + // Wait for courses to load (either card or empty state) + const hasCards = await page.locator('.q-badge').first().isVisible().catch(() => false); + const isEmpty = await page.getByText('āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļŦāļĨāļąāļāļŠāļđāļ•āļĢ').isVisible().catch(() => false); + + // Either courses exist with badges or empty state shows + expect(hasCards || isEmpty).toBeTruthy(); + }); + + test('should show course action menu', async ({ page }) => { + const moreBtn = page.locator('button').filter({ has: page.locator('.q-icon:has-text("more_vert")') }).first(); + const hasCourses = await moreBtn.isVisible().catch(() => false); + + if (hasCourses) { + await moreBtn.click(); + + // Menu should show duplicate and delete options + await expect(page.getByText('āļ—āļģāļŠāļģāđ€āļ™āļē')).toBeVisible(); + await expect(page.getByText('āļĨāļš')).toBeVisible(); + await page.waitForTimeout(1000); + } + }); + + test('should open clone dialog from menu', async ({ page }) => { + const moreBtn = page.locator('button').filter({ has: page.locator('.q-icon:has-text("more_vert")') }).first(); + const hasCourses = await moreBtn.isVisible().catch(() => false); + + if (hasCourses) { + await moreBtn.click(); + await page.getByText('āļ—āļģāļŠāļģāđ€āļ™āļē').click(); + + // Clone dialog should appear + await expect(page.getByText('āļ—āļģāļŠāļģāđ€āļ™āļēāļŦāļĨāļąāļāļŠāļđāļ•āļĢ')).toBeVisible(); + await expect(page.locator('.q-dialog input').first()).toBeVisible(); + await page.waitForTimeout(1000); + } + }); + + test('should handle rejected course view details', async ({ page }) => { + // Filter by rejected status + await page.locator('.q-select').first().click(); + await page.getByRole('listbox').getByText('āļ–āļđāļāļ›āļāļīāđ€āļŠāļ˜').click(); + await page.waitForLoadState('networkidle'); + + // If there are rejected courses, clicking view should show rejection dialog + const viewBtn = page.locator('button').filter({ has: page.locator('.q-icon:has-text("visibility")') }).first(); + const hasRejected = await viewBtn.isVisible().catch(() => false); + + if (hasRejected) { + await viewBtn.click(); + + // Rejection dialog should appear + await expect(page.getByText('āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ–āļđāļāļ›āļāļīāđ€āļŠāļ˜')).toBeVisible(); + await expect(page.getByText('āđ€āļŦāļ•āļļāļœāļĨāļāļēāļĢāļ›āļāļīāđ€āļŠāļ˜')).toBeVisible(); + await expect(page.getByRole('button', { name: /āļ„āļ·āļ™āļŠāļ–āļēāļ™āļ°āđ€āļ›āđ‡āļ™āđāļšāļšāļĢāđˆāļēāļ‡/ })).toBeVisible(); + await page.waitForTimeout(1000); + } + }); +}); diff --git a/frontend_management/tests/instructor/create-course.spec.ts b/frontend_management/tests/instructor/create-course.spec.ts new file mode 100644 index 00000000..a4b2bffc --- /dev/null +++ b/frontend_management/tests/instructor/create-course.spec.ts @@ -0,0 +1,417 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; +import { faker } from '@faker-js/faker'; + +/** + * Instructor Create Course & Structure Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ instructor-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + * āđƒāļŠāđ‰ faker āļŠāļļāđˆāļĄāļ‚āđ‰āļ­āļĄāļđāļĨāļŦāļĨāļąāļāļŠāļđāļ•āļĢāļˆāļĢāļīāļ‡āđ† + */ + +// ── Dynamic Course Data Generator (āđ„āļĄāđˆ hardcode) ──────────────── +const TOPICS = ['JavaScript', 'Python', 'React', 'Vue.js', 'Node.js', 'TypeScript', 'Docker', 'Kubernetes', 'GraphQL', 'Next.js', 'Flutter', 'Swift', 'Rust', 'Go', 'Machine Learning']; +const LEVELS = ['āđ€āļšāļ·āđ‰āļ­āļ‡āļ•āđ‰āļ™', 'āļžāļ·āđ‰āļ™āļāļēāļ™', 'āļ‚āļąāđ‰āļ™āļŠāļđāļ‡', 'āļŠāļģāļŦāļĢāļąāļšāļĄāļ·āļ­āđƒāļŦāļĄāđˆ', 'āđ€āļŠāļīāļ‡āļ›āļāļīāļšāļąāļ•āļī']; +const LEVELS_EN = ['Fundamentals', 'Basics', 'Advanced', 'for Beginners', 'Hands-on']; +const ACTIONS_TH = ['āđ€āļĢāļĩāļĒāļ™āļĢāļđāđ‰', 'āļ—āļģāļ„āļ§āļēāļĄāđ€āļ‚āđ‰āļēāđƒāļˆ', 'āļāļķāļāļ›āļāļīāļšāļąāļ•āļī', 'āļ›āļĢāļ°āļĒāļļāļāļ•āđŒāđƒāļŠāđ‰', 'āļŠāļģāļĢāļ§āļˆ']; +const ACTIONS_EN = ['Learn', 'Understand', 'Practice', 'Apply', 'Explore']; +const CHAPTER_THEMES_TH = ['āđāļ™āļ°āļ™āļģ', 'āļžāļ·āđ‰āļ™āļāļēāļ™', 'āļāļēāļĢāđƒāļŠāđ‰āļ‡āļēāļ™', 'āđ€āļ—āļ„āļ™āļīāļ„', 'āđ‚āļ›āļĢāđ€āļˆāļāļ•āđŒ']; +const CHAPTER_THEMES_EN = ['Introduction to', 'Basics of', 'Working with', 'Techniques in', 'Projects with']; +const LESSON_TYPES: ('āļ§āļīāļ”āļĩāđ‚āļ­' | 'āđāļšāļšāļ—āļ”āļŠāļ­āļš')[] = ['āļ§āļīāļ”āļĩāđ‚āļ­', 'āļ§āļīāļ”āļĩāđ‚āļ­', 'āļ§āļīāļ”āļĩāđ‚āļ­', 'āđāļšāļšāļ—āļ”āļŠāļ­āļš']; // 75% video, 25% quiz + +const timestamp = Date.now(); +const topic = faker.helpers.arrayElement(TOPICS); +const levelIndex = faker.number.int({ min: 0, max: LEVELS.length - 1 }); + +/** āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨ lesson āļŠāļļāđˆāļĄ */ +function generateLesson(topic: string, chapterIndex: number, lessonIndex: number) { + const subtopic = faker.helpers.arrayElement([ + `${faker.helpers.arrayElement(ACTIONS_TH)} ${topic}`, + `${topic} ${faker.helpers.arrayElement(['āļ•āļ­āļ™āļ—āļĩāđˆ', 'āļŠāđˆāļ§āļ™āļ—āļĩāđˆ', 'āļŦāļąāļ§āļ‚āđ‰āļ­āļ—āļĩāđˆ'])} ${chapterIndex + 1}.${lessonIndex + 1}`, + `${faker.helpers.arrayElement(['āļāļēāļĢāļ•āļąāđ‰āļ‡āļ„āđˆāļē', 'āļāļēāļĢāđƒāļŠāđ‰āļ‡āļēāļ™', 'āđāļ™āļ§āļ„āļīāļ”', 'āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡', 'āļāļēāļĢāļ—āļ”āļĨāļ­āļ‡'])} ${topic} āļšāļ—āļ—āļĩāđˆ ${chapterIndex + 1}`, + ]); + const subtopicEn = faker.helpers.arrayElement([ + `${faker.helpers.arrayElement(ACTIONS_EN)} ${topic}`, + `${topic} Part ${chapterIndex + 1}.${lessonIndex + 1}`, + `${faker.helpers.arrayElement(['Setting up', 'Using', 'Concepts of', 'Examples of', 'Experimenting with'])} ${topic} Ch.${chapterIndex + 1}`, + ]); + const type = faker.helpers.arrayElement(LESSON_TYPES); + + return { + title: { + th: type === 'āđāļšāļšāļ—āļ”āļŠāļ­āļš' ? `āđāļšāļšāļ—āļ”āļŠāļ­āļš ${chapterIndex + 1}.${lessonIndex + 1}: ${subtopic}` : subtopic, + en: type === 'āđāļšāļšāļ—āļ”āļŠāļ­āļš' ? `Quiz ${chapterIndex + 1}.${lessonIndex + 1}: ${subtopicEn}` : subtopicEn, + }, + type, + content: { + th: `āđƒāļ™āļšāļ—āđ€āļĢāļĩāļĒāļ™āļ™āļĩāđ‰āļ„āļļāļ“āļˆāļ°āđ„āļ”āđ‰${faker.helpers.arrayElement(ACTIONS_TH)} ${topic} ${faker.helpers.arrayElement(['āđāļšāļšāđ€āļˆāļēāļ°āļĨāļķāļ', 'āļ­āļĒāđˆāļēāļ‡āļĨāļ°āđ€āļ­āļĩāļĒāļ”', 'āļœāđˆāļēāļ™āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡āļˆāļĢāļīāļ‡', 'āļžāļĢāđ‰āļ­āļĄāđāļšāļšāļāļķāļāļŦāļąāļ”'])}`, + en: `In this lesson you will ${faker.helpers.arrayElement(ACTIONS_EN).toLowerCase()} ${topic} ${faker.helpers.arrayElement(['in depth', 'in detail', 'through real examples', 'with exercises'])}`, + }, + }; +} + +/** āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨ chapter āļŠāļļāđˆāļĄ */ +function generateChapter(topic: string, chapterIndex: number, themeIndex: number) { + const lessonCount = faker.number.int({ min: 2, max: 3 }); + const lessons = Array.from({ length: lessonCount }, (_, i) => generateLesson(topic, chapterIndex, i)); + + // āļ—āļģāđƒāļŦāđ‰ lesson āļŠāļļāļ”āļ—āđ‰āļēāļĒāđ€āļ›āđ‡āļ™āđāļšāļšāļ—āļ”āļŠāļ­āļšāđ€āļŠāļĄāļ­ + lessons[lessons.length - 1] = { + ...lessons[lessons.length - 1], + type: 'āđāļšāļšāļ—āļ”āļŠāļ­āļš', + title: { + th: `āđāļšāļšāļ—āļ”āļŠāļ­āļš: ${CHAPTER_THEMES_TH[themeIndex]} ${topic}`, + en: `Quiz: ${CHAPTER_THEMES_EN[themeIndex]} ${topic}`, + }, + }; + + return { + title: { + th: `${CHAPTER_THEMES_TH[themeIndex]} ${topic}`, + en: `${CHAPTER_THEMES_EN[themeIndex]} ${topic}`, + }, + description: { + th: `${faker.helpers.arrayElement(ACTIONS_TH)} ${CHAPTER_THEMES_TH[themeIndex].toLowerCase()} ${topic} ${faker.helpers.arrayElement(['āļ­āļĒāđˆāļēāļ‡āđ€āļ›āđ‡āļ™āļĢāļ°āļšāļš', 'āļ­āļĒāđˆāļēāļ‡āļĄāļĩāļ›āļĢāļ°āļŠāļīāļ—āļ˜āļīāļ āļēāļž', 'āđāļšāļš step-by-step', 'āļœāđˆāļēāļ™āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡āļˆāļĢāļīāļ‡'])}`, + en: `${faker.helpers.arrayElement(ACTIONS_EN)} ${CHAPTER_THEMES_EN[themeIndex].toLowerCase()} ${topic} ${faker.helpers.arrayElement(['systematically', 'effectively', 'step by step', 'through real examples'])}`, + }, + lessons, + }; +} + +// ── āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨ course ────────────────────────────────────────── +const COURSE_DATA = { + title: { + th: `${topic} ${LEVELS[levelIndex]} ${timestamp}`, + en: `${topic} ${LEVELS_EN[levelIndex]} ${timestamp}`, + }, + slug: `${topic.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${timestamp}`, + description: { + th: `āđ€āļĢāļĩāļĒāļ™āļĢāļđāđ‰ ${topic} ${LEVELS[levelIndex]} āļ„āļĢāļ­āļšāļ„āļĨāļļāļĄāđ€āļ™āļ·āđ‰āļ­āļŦāļēāļŠāļģāļ„āļąāļāļ—āļąāđ‰āļ‡āļŦāļĄāļ”āļ—āļĩāđˆāļˆāļģāđ€āļ›āđ‡āļ™ āļžāļĢāđ‰āļ­āļĄāļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡āđāļĨāļ°āđāļšāļšāļāļķāļāļŦāļąāļ”āļ—āļĩāđˆāļˆāļ°āļŠāđˆāļ§āļĒāđƒāļŦāđ‰āļ„āļļāļ“āđ€āļ‚āđ‰āļēāđƒāļˆāļ­āļĒāđˆāļēāļ‡āļĨāļķāļāļ‹āļķāđ‰āļ‡`, + en: `Learn ${topic} ${LEVELS_EN[levelIndex]} covering all essential topics with examples and exercises to help you gain deep understanding.`, + }, +}; + +const chapterCount = faker.number.int({ min: 2, max: 3 }); +// āļŠāļļāđˆāļĄ theme āļ—āļĩāđˆāđ„āļĄāđˆāļ‹āđ‰āļģāļāļąāļ™āļŠāļģāļŦāļĢāļąāļšāđāļ•āđˆāļĨāļ°āļšāļ— +const shuffledThemeIndexes = faker.helpers.shuffle([0, 1, 2, 3, 4]).slice(0, chapterCount); +const CHAPTERS = shuffledThemeIndexes.map((themeIdx, i) => generateChapter(topic, i, themeIdx)); + +// ── Tests (serial — āļ•āđ‰āļ­āļ‡āļ—āļģāļ‡āļēāļ™āļ•āđˆāļ­āđ€āļ™āļ·āđˆāļ­āļ‡āļāļąāļ™) ───────────────────── +test.describe.serial('Create Course & Structure', () => { + let courseUrl: string; + + // ── Step 1: āļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢāđƒāļŦāļĄāđˆ ── + test('create a new course with faker data', async ({ page }) => { + await page.goto(TEST_URLS.instructorCreateCourse); + await page.waitForLoadState('networkidle'); + + // āļāļĢāļ­āļāļŠāļ·āđˆāļ­āļŦāļĨāļąāļāļŠāļđāļ•āļĢ + await page.locator('input').filter({ hasText: '' }).nth(0) + .or(page.getByLabel('āļŠāļ·āđˆāļ­āļŦāļĨāļąāļāļŠāļđāļ•āļĢ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *')) + .fill(COURSE_DATA.title.th); + + await page.getByLabel('āļŠāļ·āđˆāļ­āļŦāļĨāļąāļāļŠāļđāļ•āļĢ (English) *').fill(COURSE_DATA.title.en); + + // āļāļĢāļ­āļ Slug + await page.getByLabel('Slug (URL) *').clear(); + await page.getByLabel('Slug (URL) *').fill(COURSE_DATA.slug); + + // āđ€āļĨāļ·āļ­āļāļŦāļĄāļ§āļ”āļŦāļĄāļđāđˆ (āđ€āļĨāļ·āļ­āļāļ•āļąāļ§āđāļĢāļāļˆāļēāļ dropdown) + await page.locator('.q-select').click(); + await page.waitForTimeout(300); + await page.getByRole('listbox').locator('.q-item').first().click(); + + // āļāļĢāļ­āļāļ„āļģāļ­āļ˜āļīāļšāļēāļĒ + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(COURSE_DATA.description.th); + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (English) *').fill(COURSE_DATA.description.en); + + // āļ•āļąāđ‰āļ‡āļ„āđˆāļē — āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļŸāļĢāļĩ (toggle āļ­āļĒāļđāđˆāđāļĨāđ‰āļ§āđ€āļ›āđ‡āļ™ default) + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē "āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļŸāļĢāļĩ" toggle āđ€āļ›āļīāļ”āļ­āļĒāļđāđˆ + const freeToggle = page.getByText('āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļŸāļĢāļĩ'); + await expect(freeToggle).toBeVisible(); + + // āļāļ”āļŠāļĢāđ‰āļēāļ‡ + await page.getByRole('button', { name: 'āļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢ' }).click(); + + // āļĢāļ­ redirect āđ„āļ›āļŦāļ™āđ‰āļē course detail + await page.waitForURL('**/instructor/courses/*', { timeout: 15_000 }); + await expect(page).toHaveURL(/\/instructor\/courses\/\d+/); + + // āđ€āļāđ‡āļš URL āļŠāļģāļŦāļĢāļąāļš test āļ–āļąāļ”āđ„āļ› + courseUrl = page.url(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļŠāļ·āđˆāļ­ course āđāļŠāļ”āļ‡āļšāļ™āļŦāļ™āđ‰āļē detail + await page.waitForLoadState('networkidle'); + await expect(page.getByText(COURSE_DATA.title.th)).toBeVisible({ timeout: 10_000 }); + }); + + // ── Step 2: āļ­āļąāļ›āđ‚āļŦāļĨāļ”āļĢāļđāļ›āļŦāļĨāļąāļāļŠāļđāļ•āļĢ â”€â”€ + test('upload course thumbnail', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļŠāļĢāđ‰āļēāļ‡āđ„āļŸāļĨāđŒāļĢāļđāļ›āļ—āļ”āļŠāļ­āļš (1x1 PNG āļ‚āļ™āļēāļ”āđ€āļĨāđ‡āļ) + const pngBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 'base64' + ); + + // āļāļ”āļ—āļĩāđˆ thumbnail area āđ€āļžāļ·āđˆāļ­āđ€āļ›āļīāļ” file input + const fileInput = page.locator('input[type="file"][accept="image/*"]'); + await fileInput.setInputFiles({ + name: `test-thumbnail-${Date.now()}.png`, + mimeType: 'image/png', + buffer: pngBuffer, + }); + + // āļĢāļ­ upload āđ€āļŠāļĢāđ‡āļˆ + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļĢāļđāļ›āđāļŠāļ”āļ‡ (img tag āļ›āļĢāļēāļāļ) + await expect(page.locator('img[alt]').first()).toBeVisible({ timeout: 10_000 }); + }); + + // ── Step 3: āđ„āļ›āļŦāļ™āđ‰āļēāļˆāļąāļ”āļāļēāļĢāđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡ â”€â”€ + test('navigate to structure page', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ”āļ›āļļāđˆāļĄ "āļˆāļąāļ”āļāļēāļĢāđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡" āđƒāļ™ Structure tab + await page.getByRole('button', { name: 'āļˆāļąāļ”āļāļēāļĢāđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡' }).click(); + await page.waitForURL('**/structure**', { timeout: 10_000 }); + await expect(page).toHaveURL(/\/structure/); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš header + await expect(page.getByText('āļˆāļąāļ”āļāļēāļĢāđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āļŦāļĨāļąāļāļŠāļđāļ•āļĢ')).toBeVisible(); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš empty state + await expect(page.getByText('āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļšāļ—āđ€āļĢāļĩāļĒāļ™')).toBeVisible(); + }); + + // ── Step 4: āđ€āļžāļīāđˆāļĄāļšāļ—āļ—āļĩāđˆ 1 ── + test('add chapter 1 with faker data', async ({ page }) => { + // Navigate to structure page + const structureUrl = courseUrl + '/structure'; + await page.goto(structureUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ”āđ€āļžāļīāđˆāļĄāļšāļ—āđāļĢāļ + await page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļšāļ—āđāļĢāļ' }).click(); + await page.waitForTimeout(300); + + // āļāļĢāļ­āļāļ‚āđ‰āļ­āļĄāļđāļĨ chapter + const chapter = CHAPTERS[0]; + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ— (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(chapter.title.th); + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ— (English) *').fill(chapter.title.en); + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(chapter.description.th); + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (English) *').fill(chapter.description.en); + + // āļšāļąāļ™āļ—āļķāļ + await page.getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' }).click(); + await page.waitForLoadState('networkidle'); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš chapter āđāļŠāļ”āļ‡āļœāļĨ + await expect(page.getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 }); + }); + + // ── Step 5: āđ€āļžāļīāđˆāļĄ lessons āđƒāļ™āļšāļ—āļ—āļĩāđˆ 1 ── + test('add lessons to chapter 1', async ({ page }) => { + const structureUrl = courseUrl + '/structure'; + await page.goto(structureUrl); + await page.waitForLoadState('networkidle'); + + const chapter = CHAPTERS[0]; + + for (const lesson of chapter.lessons) { + // āļāļ”āļ›āļļāđˆāļĄāđ€āļžāļīāđˆāļĄāļšāļ—āđ€āļĢāļĩāļĒāļ™ (āļ›āļļāđˆāļĄ + āļ—āļĩāđˆāļ­āļĒāļđāđˆāļ‚āđ‰āļēāļ‡ chapter header) + const chapterCard = page.locator('.q-card').filter({ hasText: chapter.title.th }); + await chapterCard.locator('button').filter({ has: page.locator('.q-icon:has-text("add")') }).click(); + await page.waitForTimeout(300); + + // āļāļĢāļ­āļāļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(lesson.title.th); + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ (English) *').fill(lesson.title.en); + + // āđ€āļĨāļ·āļ­āļāļ›āļĢāļ°āđ€āļ āļ— + await page.locator('.q-dialog .q-select').click(); + await page.waitForTimeout(200); + await page.getByRole('listbox').getByText(lesson.type).click(); + + // āļāļĢāļ­āļāđ€āļ™āļ·āđ‰āļ­āļŦāļē + await page.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(lesson.content.th); + await page.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (English) *').fill(lesson.content.en); + + // āļšāļąāļ™āļ—āļķāļ + await page.locator('.q-dialog').getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' }).click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē lesson āđāļŠāļ”āļ‡ + await expect(page.getByText(lesson.title.th)).toBeVisible({ timeout: 10_000 }); + } + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļˆāļģāļ™āļ§āļ™āļšāļ—āđ€āļĢāļĩāļĒāļ™ + await expect(page.getByText(`${chapter.lessons.length} āļšāļ—āđ€āļĢāļĩāļĒāļ™`)).toBeVisible(); + }); + + // ── Step 6: āđ€āļžāļīāđˆāļĄāļšāļ—āļ—āļĩāđˆ 2 ── + test('add chapter 2 with faker data', async ({ page }) => { + const structureUrl = courseUrl + '/structure'; + await page.goto(structureUrl); + await page.waitForLoadState('networkidle'); + + // āļāļ”āđ€āļžāļīāđˆāļĄāļšāļ— (āļ›āļļāđˆāļĄ header) + await page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļšāļ—' }).click(); + await page.waitForTimeout(300); + + // āļāļĢāļ­āļāļ‚āđ‰āļ­āļĄāļđāļĨ chapter 2 + const chapter = CHAPTERS[1]; + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ— (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(chapter.title.th); + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ— (English) *').fill(chapter.title.en); + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(chapter.description.th); + await page.getByLabel('āļ„āļģāļ­āļ˜āļīāļšāļēāļĒ (English) *').fill(chapter.description.en); + + // āļšāļąāļ™āļ—āļķāļ + await page.getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' }).click(); + await page.waitForLoadState('networkidle'); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš chapter 2 āđāļŠāļ”āļ‡āļœāļĨ + await expect(page.getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 }); + }); + + // ── Step 7: āđ€āļžāļīāđˆāļĄ lessons āđƒāļ™āļšāļ—āļ—āļĩāđˆ 2 ── + test('add lessons to chapter 2', async ({ page }) => { + const structureUrl = courseUrl + '/structure'; + await page.goto(structureUrl); + await page.waitForLoadState('networkidle'); + + const chapter = CHAPTERS[1]; + + for (const lesson of chapter.lessons) { + // āļāļ”āļ›āļļāđˆāļĄāđ€āļžāļīāđˆāļĄāļšāļ—āđ€āļĢāļĩāļĒāļ™āļ‚āļ­āļ‡āļšāļ—āļ—āļĩāđˆ 2 (āļ›āļļāđˆāļĄāļ—āļĩāđˆ 2) + // āļŦāļēāļšāļ—āļ—āļĩāđˆ 2 āļāđˆāļ­āļ™ āđāļĨāđ‰āļ§āļāļ”āļ›āļļāđˆāļĄ add āļ‚āļ­āļ‡ chapter āļ™āļąāđ‰āļ™ + const chapterCard = page.locator('.q-card').filter({ hasText: chapter.title.th }); + await chapterCard.locator('button').filter({ has: page.locator('.q-icon:text("add")') }).click(); + await page.waitForTimeout(300); + + // āļāļĢāļ­āļāļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(lesson.title.th); + await page.getByLabel('āļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ (English) *').fill(lesson.title.en); + + // āđ€āļĨāļ·āļ­āļāļ›āļĢāļ°āđ€āļ āļ— + await page.locator('.q-dialog .q-select').click(); + await page.waitForTimeout(200); + await page.getByRole('listbox').getByText(lesson.type).click(); + + // āļāļĢāļ­āļāđ€āļ™āļ·āđ‰āļ­āļŦāļē + await page.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(lesson.content.th); + await page.getByLabel('āđ€āļ™āļ·āđ‰āļ­āļŦāļē (English) *').fill(lesson.content.en); + + // āļšāļąāļ™āļ—āļķāļ + await page.locator('.q-dialog').getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' }).click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē lesson āđāļŠāļ”āļ‡ + await expect(page.getByText(lesson.title.th)).toBeVisible({ timeout: 10_000 }); + } + }); + + // ── Step 8: āđ€āļžāļīāđˆāļĄāļ‚āđ‰āļ­āļŠāļ­āļšāđƒāļ™āđāļšāļšāļ—āļ”āļŠāļ­āļš â”€â”€ + test('add quiz questions to a quiz lesson', async ({ page }) => { + const structureUrl = courseUrl + '/structure'; + await page.goto(structureUrl); + await page.waitForLoadState('networkidle'); + + // āļŦāļē quiz lesson āđāļĢāļāļˆāļēāļāļšāļ—āđāļĢāļ â†’ āļāļ”āļ›āļļāđˆāļĄ edit (icon "edit") āđ€āļžāļ·āđˆāļ­āđ€āļ‚āđ‰āļēāļŦāļ™āđ‰āļē quiz + const firstQuizLesson = CHAPTERS[0].lessons.find(l => l.type === 'āđāļšāļšāļ—āļ”āļŠāļ­āļš'); + if (!firstQuizLesson) throw new Error('No quiz lesson found in chapter 1'); + + // Locate the quiz lesson row and click its edit button + const quizRow = page.locator('.q-item').filter({ hasText: firstQuizLesson.title.th }); + await quizRow.locator('button').filter({ has: page.locator('.q-icon:has-text("edit")') }).click(); + + // āļĢāļ­āđ€āļ‚āđ‰āļēāļŦāļ™āđ‰āļē quiz + await page.waitForURL('**/quiz', { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + await expect(page.getByText('āđāļāđ‰āđ„āļ‚āļšāļ—āđ€āļĢāļĩāļĒāļ™ (āđāļšāļšāļ—āļ”āļŠāļ­āļš)')).toBeVisible(); + + // āļŠāļļāđˆāļĄāļˆāļģāļ™āļ§āļ™āļ‚āđ‰āļ­āļŠāļ­āļš 2-4 āļ‚āđ‰āļ­ + const questionCount = faker.number.int({ min: 2, max: 4 }); + + for (let q = 0; q < questionCount; q++) { + const questionTh = `${faker.helpers.arrayElement(ACTIONS_TH)} ${topic} āļ„āļģāļ–āļēāļĄāļ—āļĩāđˆ ${q + 1}: ${faker.helpers.arrayElement([ + 'āļ‚āđ‰āļ­āđƒāļ”āļ–āļđāļāļ•āđ‰āļ­āļ‡?', + 'āļ‚āđ‰āļ­āđƒāļ”āđ€āļ›āđ‡āļ™āļˆāļĢāļīāļ‡?', + 'āļ‚āđ‰āļ­āđƒāļ”āļ„āļ·āļ­āļ„āļģāļ•āļ­āļšāļ—āļĩāđˆāļ”āļĩāļ—āļĩāđˆāļŠāļļāļ”?', + 'āļ‚āđ‰āļ­āđƒāļ”āļ•āđˆāļ­āđ„āļ›āļ™āļĩāđ‰āļ–āļđāļāļ•āđ‰āļ­āļ‡?', + ])}`; + const questionEn = `${faker.helpers.arrayElement(ACTIONS_EN)} ${topic} Question ${q + 1}: ${faker.helpers.arrayElement([ + 'Which is correct?', + 'Which is true?', + 'Which is the best answer?', + 'Which of the following is correct?', + ])}`; + + // āļāļ”āļ›āļļāđˆāļĄ "āđ€āļžāļīāđˆāļĄāļ„āļģāļ–āļēāļĄ" + await page.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļ„āļģāļ–āļēāļĄ' }).click(); + await page.waitForTimeout(300); + + // āļāļĢāļ­āļāļ„āļģāļ–āļēāļĄ + const dialog = page.locator('.q-dialog'); + await dialog.getByLabel('āļ„āļģāļ–āļēāļĄ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *').fill(questionTh); + await dialog.getByLabel('āļ„āļģāļ–āļēāļĄ (English)').fill(questionEn); + + // āđ€āļžāļīāđˆāļĄāļ•āļąāļ§āđ€āļĨāļ·āļ­āļ (default āļĄāļĩ 2 āļ•āļąāļ§ â†’ āđ€āļžāļīāđˆāļĄāļ­āļĩāļ 2 āđƒāļŦāđ‰āļ„āļĢāļš 4) + await dialog.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļ•āļąāļ§āđ€āļĨāļ·āļ­āļ' }).click(); + await dialog.getByRole('button', { name: 'āđ€āļžāļīāđˆāļĄāļ•āļąāļ§āđ€āļĨāļ·āļ­āļ' }).click(); + + // āļāļĢāļ­āļ 4 āļ•āļąāļ§āđ€āļĨāļ·āļ­āļ + const choices = [ + { th: `${topic} āļ„āļģāļ•āļ­āļšāļ—āļĩāđˆāļ–āļđāļāļ•āđ‰āļ­āļ‡ ${q + 1}`, en: `${topic} correct answer ${q + 1}` }, + { th: `${topic} āļ•āļąāļ§āđ€āļĨāļ·āļ­āļāļ—āļĩāđˆ 2 ${q + 1}`, en: `${topic} option 2 ${q + 1}` }, + { th: `${topic} āļ•āļąāļ§āđ€āļĨāļ·āļ­āļāļ—āļĩāđˆ 3 ${q + 1}`, en: `${topic} option 3 ${q + 1}` }, + { th: `${topic} āļ•āļąāļ§āđ€āļĨāļ·āļ­āļāļ—āļĩāđˆ 4 ${q + 1}`, en: `${topic} option 4 ${q + 1}` }, + ]; + + for (let c = 0; c < 4; c++) { + await dialog.getByLabel(`āļ•āļąāļ§āđ€āļĨāļ·āļ­āļ ${c + 1} (TH)`).fill(choices[c].th); + await dialog.getByLabel(`āļ•āļąāļ§āđ€āļĨāļ·āļ­āļ ${c + 1} (EN)`).fill(choices[c].en); + } + + // āļ•āļąāļ§āđ€āļĨāļ·āļ­āļāđāļĢāļ (index 0) āđ€āļ›āđ‡āļ™āļ„āļģāļ•āļ­āļšāļ—āļĩāđˆāļ–āļđāļ (default āđ€āļĨāļ·āļ­āļāđ„āļ§āđ‰āđāļĨāđ‰āļ§) + + // āļšāļąāļ™āļ—āļķāļ + await dialog.getByRole('button', { name: 'āļšāļąāļ™āļ—āļķāļ' }).click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļ„āļģāļ–āļēāļĄāđāļŠāļ”āļ‡āđƒāļ™āļĢāļēāļĒāļāļēāļĢ + await expect(page.getByText(`āļ„āļģāļ–āļēāļĄāļ—āļĩāđˆ ${q + 1}`).first()).toBeVisible({ timeout: 10_000 }); + } + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļˆāļģāļ™āļ§āļ™āļ‚āđ‰āļ­āļŠāļ­āļšāļ—āļąāđ‰āļ‡āļŦāļĄāļ” + await expect(page.getByText(`āļ„āļģāļ–āļēāļĄ (${questionCount} āļ‚āđ‰āļ­)`)).toBeVisible(); + }); + + // ── Step 9: āļ•āļĢāļ§āļˆāļŠāļ­āļšāđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āļ—āļąāđ‰āļ‡āļŦāļĄāļ”āļˆāļēāļāļŦāļ™āđ‰āļē course detail ── + test('verify full structure on course detail page', async ({ page }) => { + await page.goto(courseUrl); + await page.waitForLoadState('networkidle'); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē tab āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āđāļŠāļ”āļ‡ chapters āļ—āļąāđ‰āļ‡āļŦāļĄāļ” + for (const chapter of CHAPTERS) { + // āļ•āļĢāļ§āļˆāļŠāļ­āļš chapter title (scope āđ„āļ›āļ—āļĩāđˆ header element āđ€āļžāļ·āđˆāļ­āļāļąāļ™ text āļ‹āđ‰āļģ) + await expect(page.locator('.font-semibold').getByText(chapter.title.th)).toBeVisible({ timeout: 10_000 }); + + // āļ•āļĢāļ§āļˆāļŠāļ­āļš lessons āđƒāļ™ chapter + for (const lesson of chapter.lessons) { + await expect(page.locator('.q-item__label').getByText(lesson.title.th)).toBeVisible(); + } + } + + // āļ•āļĢāļ§āļˆāļŠāļ­āļšāļˆāļģāļ™āļ§āļ™āļšāļ—āđ€āļĢāļĩāļĒāļ™āļĢāļ§āļĄ + const totalLessons = CHAPTERS.reduce((sum, ch) => sum + ch.lessons.length, 0); + await expect(page.getByText(`${totalLessons} āļšāļ—āđ€āļĢāļĩāļĒāļ™`)).toBeVisible(); + }); +}); diff --git a/frontend_management/tests/instructor/dashboard.spec.ts b/frontend_management/tests/instructor/dashboard.spec.ts new file mode 100644 index 00000000..134a1f6b --- /dev/null +++ b/frontend_management/tests/instructor/dashboard.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { TEST_URLS } from '../fixtures/test-data'; + +/** + * Instructor Dashboard Tests + * āđƒāļŠāđ‰ cookies āļˆāļēāļ instructor-setup project (āđ„āļĄāđˆāļ•āđ‰āļ­āļ‡ login āļ‹āđ‰āļģ) + */ +test.describe('Instructor Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TEST_URLS.instructorDashboard); + await page.waitForLoadState('networkidle'); + }); + + test('check display dashboard', async ({ page }) => { + await expect(page.getByText('āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + await expect(page.getByText('āļœāļđāđ‰āđ€āļĢāļĩāļĒāļ™āļ—āļąāđ‰āļ‡āļŦāļĄāļ”')).toBeVisible(); + await expect(page.getByText('āđ€āļĢāļĩāļĒāļ™āļˆāļšāđāļĨāđ‰āļ§')).toBeVisible(); + await expect(page.getByText('āļŠāļ–āļēāļ™āļ°āļŦāļĨāļąāļāļŠāļđāļ•āļĢ')).toBeVisible(); + await expect(page.getByRole('button', { name: 'āļ”āļđāļ—āļąāđ‰āļ‡āļŦāļĄāļ”' })).toBeVisible(); + }); + + test('should navigate to courses list', async ({ page }) => { + await page.getByRole('button', { name: 'āļ”āļđāļ—āļąāđ‰āļ‡āļŦāļĄāļ”' }).click(); + await page.waitForURL('**/instructor/courses**'); + await expect(page).toHaveURL(/\/instructor\/courses/); + }); + + test('should show user menu on avatar click', async ({ page }) => { + await page.locator('.w-12.h-12.rounded-full').click(); + + await expect(page.getByText('āđ‚āļ›āļĢāđ„āļŸāļĨāđŒ')).toBeVisible(); + }); + + test('should navigate to profile', async ({ page }) => { + await page.locator('.w-12.h-12.rounded-full').click(); + await page.getByText('āđ‚āļ›āļĢāđ„āļŸāļĨāđŒ').click(); + + await page.waitForURL('**/instructor/profile**'); + await expect(page).toHaveURL(/\/instructor\/profile/); + }); + + test('should logout and redirect to login', async ({ page }) => { + await page.locator('.w-12.h-12.rounded-full').click(); + await page.getByRole('menu').getByText('āļ­āļ­āļāļˆāļēāļāļĢāļ°āļšāļš').click(); + + // Confirm logout dialog + await page.locator('.q-dialog').getByRole('button', { name: 'āļ­āļ­āļāļˆāļēāļāļĢāļ°āļšāļš' }).click(); + + await page.waitForURL('**/login**', { timeout: 10_000 }); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/frontend_management/utils/date.ts b/frontend_management/utils/date.ts new file mode 100644 index 00000000..1ce80cda --- /dev/null +++ b/frontend_management/utils/date.ts @@ -0,0 +1,33 @@ +/** + * Format a date string into Thai locale format (Date only) + * Example: 10 āļĄ.āļ„. 67 + */ +export const formatDate = (date: string | Date | null | undefined): string => { + if (!date) return '-'; + + const d = typeof date === 'string' ? new Date(date) : date; + + return d.toLocaleDateString('th-TH', { + day: 'numeric', + month: 'short', + year: '2-digit' + }); +}; + +/** + * Format a date string into Thai locale format (Date and Time) + * Example: 10 āļĄ.āļ„. 67 14:30 + */ +export const formatDateTime = (date: string | Date | null | undefined): string => { + if (!date) return '-'; + + const d = typeof date === 'string' ? new Date(date) : date; + + return d.toLocaleDateString('th-TH', { + day: 'numeric', + month: 'short', + year: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +}; diff --git a/tests/e2e/screenshots/discovery-curriculum.png b/tests/e2e/screenshots/discovery-curriculum.png new file mode 100644 index 00000000..d2135388 Binary files /dev/null and b/tests/e2e/screenshots/discovery-curriculum.png differ diff --git a/tests/e2e/screenshots/discovery-enroll-btn.png b/tests/e2e/screenshots/discovery-enroll-btn.png new file mode 100644 index 00000000..6030666c Binary files /dev/null and b/tests/e2e/screenshots/discovery-enroll-btn.png differ diff --git a/tests/e2e/screenshots/discovery-filter.png b/tests/e2e/screenshots/discovery-filter.png new file mode 100644 index 00000000..a27b45f8 Binary files /dev/null and b/tests/e2e/screenshots/discovery-filter.png differ diff --git a/tests/e2e/screenshots/discovery-home.png b/tests/e2e/screenshots/discovery-home.png new file mode 100644 index 00000000..ec607da6 Binary files /dev/null and b/tests/e2e/screenshots/discovery-home.png differ diff --git a/tests/e2e/screenshots/discovery-search.png b/tests/e2e/screenshots/discovery-search.png new file mode 100644 index 00000000..c7c4f1e2 Binary files /dev/null and b/tests/e2e/screenshots/discovery-search.png differ diff --git a/tests/e2e/screenshots/forgot-01-smoke.png b/tests/e2e/screenshots/forgot-01-smoke.png new file mode 100644 index 00000000..a8faf00e Binary files /dev/null and b/tests/e2e/screenshots/forgot-01-smoke.png differ diff --git a/tests/e2e/screenshots/forgot-02-thai-email.png b/tests/e2e/screenshots/forgot-02-thai-email.png new file mode 100644 index 00000000..1b1f24fd Binary files /dev/null and b/tests/e2e/screenshots/forgot-02-thai-email.png differ diff --git a/tests/e2e/screenshots/forgot-03-back-login.png b/tests/e2e/screenshots/forgot-03-back-login.png new file mode 100644 index 00000000..aa698a0f Binary files /dev/null and b/tests/e2e/screenshots/forgot-03-back-login.png differ diff --git a/tests/e2e/screenshots/forgot-04-mock-success.png b/tests/e2e/screenshots/forgot-04-mock-success.png new file mode 100644 index 00000000..984f3ac3 Binary files /dev/null and b/tests/e2e/screenshots/forgot-04-mock-success.png differ diff --git a/tests/e2e/screenshots/login-invalid-email.png b/tests/e2e/screenshots/login-invalid-email.png new file mode 100644 index 00000000..ac6359bf Binary files /dev/null and b/tests/e2e/screenshots/login-invalid-email.png differ diff --git a/tests/e2e/screenshots/login-thai-email.png b/tests/e2e/screenshots/login-thai-email.png new file mode 100644 index 00000000..6c0f272d Binary files /dev/null and b/tests/e2e/screenshots/login-thai-email.png differ diff --git a/tests/e2e/screenshots/login-to-dashboard.png b/tests/e2e/screenshots/login-to-dashboard.png new file mode 100644 index 00000000..a87ab3e4 Binary files /dev/null and b/tests/e2e/screenshots/login-to-dashboard.png differ diff --git a/tests/e2e/screenshots/login-wrong-password.png b/tests/e2e/screenshots/login-wrong-password.png new file mode 100644 index 00000000..8be5fa14 Binary files /dev/null and b/tests/e2e/screenshots/login-wrong-password.png differ diff --git a/tests/e2e/screenshots/register-go-login.png b/tests/e2e/screenshots/register-go-login.png new file mode 100644 index 00000000..bab8b6f6 Binary files /dev/null and b/tests/e2e/screenshots/register-go-login.png differ diff --git a/tests/e2e/screenshots/register-happy-error.png b/tests/e2e/screenshots/register-happy-error.png new file mode 100644 index 00000000..61ce0c9f Binary files /dev/null and b/tests/e2e/screenshots/register-happy-error.png differ diff --git a/tests/e2e/screenshots/register-invalid-email-thai.png b/tests/e2e/screenshots/register-invalid-email-thai.png new file mode 100644 index 00000000..1c64a262 Binary files /dev/null and b/tests/e2e/screenshots/register-invalid-email-thai.png differ diff --git a/tests/e2e/screenshots/register-page.png b/tests/e2e/screenshots/register-page.png new file mode 100644 index 00000000..83f0d59a Binary files /dev/null and b/tests/e2e/screenshots/register-page.png differ diff --git a/tests/e2e/screenshots/register-password-mismatch.png b/tests/e2e/screenshots/register-password-mismatch.png new file mode 100644 index 00000000..8c178d9d Binary files /dev/null and b/tests/e2e/screenshots/register-password-mismatch.png differ diff --git a/tests/e2e/screenshots/register-redirect-login.png b/tests/e2e/screenshots/register-redirect-login.png new file mode 100644 index 00000000..e6489417 Binary files /dev/null and b/tests/e2e/screenshots/register-redirect-login.png differ diff --git a/tests/e2e/screenshots/student-dashboard.png b/tests/e2e/screenshots/student-dashboard.png new file mode 100644 index 00000000..15949aed Binary files /dev/null and b/tests/e2e/screenshots/student-dashboard.png differ diff --git a/tests/e2e/screenshots/student-edit-profile.png b/tests/e2e/screenshots/student-edit-profile.png new file mode 100644 index 00000000..0525558a Binary files /dev/null and b/tests/e2e/screenshots/student-edit-profile.png differ diff --git a/tests/e2e/screenshots/student-my-courses.png b/tests/e2e/screenshots/student-my-courses.png new file mode 100644 index 00000000..8ee9ac15 Binary files /dev/null and b/tests/e2e/screenshots/student-my-courses.png differ diff --git a/tests/e2e/screenshots/student-search-empty.png b/tests/e2e/screenshots/student-search-empty.png new file mode 100644 index 00000000..8ee33c97 Binary files /dev/null and b/tests/e2e/screenshots/student-search-empty.png differ