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 dac45be4..f10b99ca 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -1,10 +1,10 @@ import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; +import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; import { ListPendingCoursesResponse, GetCourseDetailForAdminResponse, - ApproveCourseBody, ApproveCourseResponse, RejectCourseBody, RejectCourseResponse, @@ -60,12 +60,12 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async approveCourse( @Request() request: any, - @Path() courseId: number, - @Body() body?: ApproveCourseBody + @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, body?.comment); + + return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); } /** @@ -87,6 +87,11 @@ export class AdminCourseApprovalController { ): 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); } } diff --git a/Backend/src/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index 5de912fc..a78c8d5a 100644 --- a/Backend/src/controllers/AuditController.ts +++ b/Backend/src/controllers/AuditController.ts @@ -169,8 +169,8 @@ export class AuditController { throw new ValidationError('No token provided'); } - if (days < 30) { - throw new ValidationError('Cannot delete logs newer than 30 days'); + if (days < 6) { + throw new ValidationError('Cannot delete logs newer than 6 days'); } const deleted = await auditService.deleteOldLogs(days); diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 3c99a3b5..81fd8b86 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -2,6 +2,7 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Delete, Contro import { ValidationError } from '../middleware/errorHandler'; import { CategoryService } from '../services/categories.service'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type'; +import { CreateCategoryValidator, UpdateCategoryValidator } from '../validators/categories.validator'; @Route('api/categories') @Tags('Categories') @@ -27,6 +28,11 @@ export class CategoriesAdminController { @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); } @@ -36,6 +42,11 @@ export class CategoriesAdminController { @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); } @@ -45,6 +56,6 @@ export class CategoriesAdminController { @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(token, id); } } \ No newline at end of file diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index f0bb43fe..7ba48f5c 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -27,6 +27,18 @@ import { UpdateQuizResponse, UpdateQuizBody, } from '../types/ChaptersLesson.typs'; +import { + CreateChapterValidator, + UpdateChapterValidator, + ReorderChapterValidator, + CreateLessonValidator, + UpdateLessonValidator, + ReorderLessonsValidator, + AddQuestionValidator, + UpdateQuestionValidator, + ReorderQuestionValidator, + UpdateQuizValidator +} from '../validators/ChaptersLesson.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -55,6 +67,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -82,6 +98,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -125,6 +145,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -170,6 +194,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -197,6 +225,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -246,6 +278,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -275,6 +311,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -300,6 +340,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -322,6 +366,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, @@ -371,6 +419,10 @@ export class ChaptersLessonInstructorController { ): 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, course_id: courseId, diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 5b698807..3657e928 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -2,28 +2,28 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, import { ValidationError } from '../middleware/errorHandler'; import { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { - createCourses, createCourseResponse, - GetMyCourseResponse, ListMyCourseResponse, - addinstructorCourseResponse, - removeinstructorCourseResponse, - setprimaryCourseInstructorResponse, + GetMyCourseResponse, UpdateMyCourse, UpdateMyCourseResponse, DeleteMyCourseResponse, submitCourseResponse, listinstructorCourseResponse, - GetCourseApprovalsResponse, - SearchInstructorResponse, + addinstructorCourseResponse, + removeinstructorCourseResponse, + setprimaryCourseInstructorResponse, GetEnrolledStudentsResponse, + GetEnrolledStudentDetailResponse, GetQuizScoresResponse, GetQuizAttemptDetailResponse, - GetEnrolledStudentDetailResponse, + GetCourseApprovalsResponse, + SearchInstructorResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, + CloneCourseResponse, } from '../types/CoursesInstructor.types'; -import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; +import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import jwt from 'jsonwebtoken'; import { config } from '../config'; @@ -41,12 +41,15 @@ export class CoursesInstructorController { @SuccessResponse('200', 'Courses retrieved successfully') @Response('401', 'Invalid or expired token') @Response('404', 'Courses not found') - public async listMyCourses(@Request() request: any): Promise { + public async listMyCourses( + @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); + return await CoursesInstructorService.listMyCourses({ token, status }); } /** @@ -99,9 +102,11 @@ export class CoursesInstructorController { @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'); - } + 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); } @@ -174,6 +179,36 @@ export class CoursesInstructorController { return await CoursesInstructorService.deleteCourse(token, courseId); } + /** + * คัดลอกคอร์ส (Clone Course) + * Clone an existing course to a new one with copied chapters, lessons, quizzes, and attachments + * @param courseId - รหัสคอร์สต้นฉบับ / Source Course ID + * @param body - ชื่อคอร์สใหม่ / New course title + */ + @Post('{courseId}/clone') + @Security('jwt', ['instructor']) + @SuccessResponse('201', 'Course cloned successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Course not found') + public async cloneCourse( + @Request() request: any, + @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, + course_id: courseId, + title: body.title + }); + return result; + } /** * ส่งคอร์สเพื่อขออนุมัติจากแอดมิน * Submit course for admin review and approval diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index afcf80b0..87a5a613 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -16,6 +16,7 @@ import { GetQuizAttemptsResponse, } from '../types/CoursesStudent.types'; import { EnrollmentStatus } from '@prisma/client'; +import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator'; @Route('api/students') @Tags('CoursesStudent') @@ -149,9 +150,11 @@ export class CoursesStudentController { @Body() body: SaveVideoProgressBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + 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, lesson_id: lessonId, @@ -225,9 +228,11 @@ export class CoursesStudentController { @Body() body: SubmitQuizBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + 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, course_id: courseId, diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index f054ef4e..0323f4ab 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -11,6 +11,7 @@ import { YouTubeVideoResponse, SetYouTubeVideoBody, } from '../types/ChaptersLesson.typs'; +import { SetYouTubeVideoValidator } from '../validators/Lessons.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -213,12 +214,8 @@ export class LessonsController { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - if (!body.youtube_video_id) { - throw new ValidationError('YouTube video ID is required'); - } - if (!body.video_title) { - throw new ValidationError('Video title is required'); - } + const { error } = SetYouTubeVideoValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.setYouTubeVideo({ token, diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 06bff36d..720bff7c 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -20,10 +20,14 @@ export class RecommendedCoursesController { @SuccessResponse('200', 'Approved courses retrieved successfully') @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') - public async listApprovedCourses(@Request() request: any): Promise { + public async listApprovedCourses( + @Request() request: any, + @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); + return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); } /** diff --git a/Backend/src/controllers/UserController.ts b/Backend/src/controllers/UserController.ts index b8169827..ccbe7c76 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -10,7 +10,8 @@ import { ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import { ChangePassword } from '../types/auth.types'; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; @@ -56,6 +57,18 @@ export class UserController { return await this.userService.updateProfile(token, 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); + } + /** * Change password * @summary Change user password using old password diff --git a/Backend/src/controllers/announcementsController.ts b/Backend/src/controllers/announcementsController.ts index 6a4b901c..8ac03c70 100644 --- a/Backend/src/controllers/announcementsController.ts +++ b/Backend/src/controllers/announcementsController.ts @@ -1,6 +1,7 @@ import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AnnouncementsService } from '../services/announcements.service'; +import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator'; import { ListAnnouncementResponse, CreateAnnouncementResponse, @@ -68,6 +69,10 @@ export class AnnouncementsController { // Parse JSON data field const parsed = JSON.parse(data) as CreateAnnouncementBody; + // Validate parsed data + const { error } = CreateAnnouncementValidator.validate(parsed); + if (error) throw new ValidationError(error.details[0].message); + return await announcementsService.createAnnouncement({ token, course_id: courseId, @@ -100,6 +105,11 @@ export class AnnouncementsController { ): 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, course_id: courseId, diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index f9446457..0596034c 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -113,7 +113,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(token: string,courseId: number): Promise { + static async getCourseDetail(token: string, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -133,7 +133,11 @@ export class AdminCourseApprovalService { }, chapters: { orderBy: { sort_order: 'asc' }, - include: { + select: { + id: true, + title: true, + sort_order: true, + is_published: true, lessons: { orderBy: { sort_order: 'asc' }, select: { diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index 9925ce80..e1b40d0c 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -10,6 +10,7 @@ import { UpdateCourseInput, createCourseResponse, GetMyCourseResponse, + ListMyCoursesInput, ListMyCourseResponse, addinstructorCourse, addinstructorCourseResponse, @@ -33,6 +34,8 @@ import { GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, + CloneCourseInput, + CloneCourseResponse, setCourseDraft, setCourseDraftResponse, } from "../types/CoursesInstructor.types"; @@ -116,12 +119,13 @@ export class CoursesInstructorService { } } - static async listMyCourses(token: string): Promise { + static async listMyCourses(input: ListMyCoursesInput): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + 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: decoded.id, + course: input.status ? { status: input.status } : undefined }, include: { course: true @@ -153,6 +157,17 @@ 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, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + operation: 'list_my_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -212,12 +227,12 @@ export class CoursesInstructorService { logger.error('Failed to retrieve course', { error }); const decoded = jwt.decode(getmyCourse.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: getmyCourse.course_id, metadata: { - operation: 'get_course', + operation: 'get_my_course', error: error instanceof Error ? error.message : String(error) } }); @@ -245,7 +260,7 @@ export class CoursesInstructorService { logger.error('Failed to update course', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -309,7 +324,7 @@ export class CoursesInstructorService { logger.error('Failed to upload thumbnail', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -352,7 +367,7 @@ export class CoursesInstructorService { logger.error('Failed to delete course', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -399,7 +414,7 @@ export class CoursesInstructorService { 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 || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: sendCourseForReview.course_id, @@ -432,7 +447,7 @@ export class CoursesInstructorService { 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 || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: setCourseDraft.course_id, @@ -478,7 +493,7 @@ export class CoursesInstructorService { logger.error('Failed to retrieve course approvals', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -550,7 +565,7 @@ export class CoursesInstructorService { logger.error('Failed to search instructors', { error }); const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -624,7 +639,7 @@ export class CoursesInstructorService { 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 || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: addinstructorCourse.course_id, @@ -669,7 +684,7 @@ export class CoursesInstructorService { 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 || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: removeinstructorCourse.course_id, @@ -730,7 +745,7 @@ export class CoursesInstructorService { 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 || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: listinstructorCourse.course_id, @@ -739,7 +754,6 @@ export class CoursesInstructorService { error: error instanceof Error ? error.message : String(error) } }); - throw error; } } @@ -779,7 +793,7 @@ export class CoursesInstructorService { logger.error('Failed to set primary instructor', { error }); const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: setprimaryCourseInstructor.course_id, @@ -905,7 +919,7 @@ export class CoursesInstructorService { logger.error(`Error getting enrolled students: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1083,7 +1097,7 @@ export class CoursesInstructorService { logger.error(`Error getting quiz scores: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1207,7 +1221,7 @@ export class CoursesInstructorService { logger.error(`Error getting quiz attempt detail: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1355,7 +1369,7 @@ export class CoursesInstructorService { logger.error(`Error getting enrolled student detail: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: input.course_id, @@ -1422,7 +1436,7 @@ export class CoursesInstructorService { logger.error(`Error getting course approval history: ${error}`); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ - userId: decoded?.id || 0, + userId: decoded?.id || undefined, action: AuditAction.ERROR, entityType: 'Course', entityId: courseId, @@ -1434,4 +1448,228 @@ export class CoursesInstructorService { throw error; } } + + /** + * Clone a course (including chapters, lessons, quizzes, attachments) + */ + static async cloneCourse(input: CloneCourseInput): Promise { + try { + const { token, course_id, title } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor + const courseInstructor = await this.validateCourseInstructor(token, course_id); + if (!courseInstructor) { + throw new ForbiddenError('You are not an instructor of this course'); + } + + // Fetch original course with all relations + const originalCourse = await prisma.course.findUnique({ + where: { id: course_id }, + include: { + chapters: { + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + orderBy: { sort_order: 'asc' }, + include: { + attachments: true, + quiz: { + include: { + questions: { + include: { + choices: true + } + } + } + } + } + } + } + } + } + }); + + if (!originalCourse) { + throw new NotFoundError('Course not found'); + } + + // Use transaction for atomic creation + const newCourse = await prisma.$transaction(async (tx) => { + // 1. Create new Course + const createdCourse = await tx.course.create({ + data: { + title: title as Prisma.InputJsonValue, + slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug + description: originalCourse.description ?? Prisma.JsonNull, + thumbnail_url: originalCourse.thumbnail_url, + category_id: originalCourse.category_id, + price: originalCourse.price, + is_free: originalCourse.is_free, + have_certificate: originalCourse.have_certificate, + status: 'DRAFT', // Reset status + created_by: decoded.id + } + }); + + // 2. Add Instructor (Requester as primary) + await tx.courseInstructor.create({ + data: { + course_id: createdCourse.id, + user_id: decoded.id, + is_primary: true + } + }); + + // Mapping for oldLessonId -> newLessonId for prerequisites + const lessonIdMap = new Map(); + const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = []; + + // 3. Clone Chapters and Lessons + for (const chapter of originalCourse.chapters) { + const newChapter = await tx.chapter.create({ + data: { + course_id: createdCourse.id, + title: chapter.title as Prisma.InputJsonValue, + description: chapter.description ?? Prisma.JsonNull, + sort_order: chapter.sort_order, + is_published: chapter.is_published + } + }); + + for (const lesson of chapter.lessons) { + const newLesson = await tx.lesson.create({ + data: { + chapter_id: newChapter.id, + title: lesson.title as Prisma.InputJsonValue, + content: lesson.content ?? Prisma.JsonNull, + type: lesson.type, + sort_order: lesson.sort_order, + is_published: lesson.is_published, + duration_minutes: lesson.duration_minutes, + prerequisite_lesson_ids: Prisma.JsonNull // Will update later + } + }); + + lessonIdMap.set(lesson.id, newLesson.id); + + // Store prerequisites for later update + if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) { + lessonsToUpdatePrerequisites.push({ + newLessonId: newLesson.id, + oldPrerequisites: lesson.prerequisite_lesson_ids as number[] + }); + } + + // Clone Attachments + if (lesson.attachments && lesson.attachments.length > 0) { + await tx.lessonAttachment.createMany({ + data: lesson.attachments.map(att => ({ + lesson_id: newLesson.id, + file_name: att.file_name, + file_path: att.file_path, // Reuse file path + file_size: att.file_size, + mime_type: att.mime_type, + sort_order: att.sort_order, + description: att.description ?? Prisma.JsonNull + })) + }); + } + + // Clone Quiz + if (lesson.quiz) { + const newQuiz = await tx.quiz.create({ + data: { + lesson_id: newLesson.id, + title: lesson.quiz.title as Prisma.InputJsonValue, + description: lesson.quiz.description ?? Prisma.JsonNull, + passing_score: lesson.quiz.passing_score, + allow_multiple_attempts: lesson.quiz.allow_multiple_attempts, + time_limit: lesson.quiz.time_limit, + 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 + } + }); + + for (const question of lesson.quiz.questions) { + await tx.question.create({ + data: { + quiz_id: newQuiz.id, + question: question.question as Prisma.InputJsonValue, + explanation: question.explanation ?? Prisma.JsonNull, + question_type: question.question_type, + score: question.score, + sort_order: question.sort_order, + choices: { + create: question.choices.map(choice => ({ + text: choice.text as Prisma.InputJsonValue, + is_correct: choice.is_correct, + sort_order: choice.sort_order + })) + } + } + }); + } + } + } + } + + // 4. Update Prerequisites + for (const item of lessonsToUpdatePrerequisites) { + const newPrerequisites = item.oldPrerequisites + .map(oldId => lessonIdMap.get(oldId)) + .filter((id): id is number => id !== undefined); + + if (newPrerequisites.length > 0) { + await tx.lesson.update({ + where: { id: item.newLessonId }, + data: { + prerequisite_lesson_ids: newPrerequisites + } + }); + } + } + + return createdCourse; + }); + + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Course', + entityId: newCourse.id, + metadata: { + operation: 'clone_course', + original_course_id: course_id, + new_course_id: newCourse.id + } + }); + + return { + code: 201, + message: 'Course cloned successfully', + data: { + id: newCourse.id, + title: newCourse.title as { th: string; en: string } + } + }; + + } 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, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'clone_course', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } + } } diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 0e9e5b86..986695b1 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -340,6 +340,19 @@ export class CoursesStudentService { throw new ForbiddenError('You are not enrolled in this course'); } + // Update last_accessed_at (fire-and-forget — ไม่ block response) + if (enrollment.status === 'ENROLLED') { + prisma.enrollment.update({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + data: { last_accessed_at: new Date() }, + }).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`)); + } + // Get all lesson progress for this user and course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ @@ -1249,17 +1262,17 @@ export class CoursesStudentService { } catch (error) { logger.error(`Error completing lesson: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; - await auditService.logSync({ - userId: decoded?.id || 0, - action: AuditAction.ERROR, - entityType: 'LessonProgress', - entityId: input.lesson_id, - metadata: { - operation: 'complete_lesson', - lesson_id: input.lesson_id, - error: error instanceof Error ? error.message : String(error) - } - }); + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonProgress', + entityId: input.lesson_id, + metadata: { + operation: 'complete_lesson', + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index 9c185c8a..22440eb2 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -8,7 +8,8 @@ import { ListApprovedCoursesResponse, GetCourseByIdResponse, ToggleRecommendedResponse, - RecommendedCourseData + RecommendedCourseData, + RecommendedCourseDetailData } from '../types/RecommendedCourses.types'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -18,10 +19,24 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses(token: string): Promise { + static async listApprovedCourses( + token: string, + filters?: { search?: string; categoryId?: number } + ): Promise { try { + const { search, categoryId } = filters ?? {}; + const courses = await prisma.course.findMany({ - where: { status: 'APPROVED' }, + where: { + status: 'APPROVED', + ...(categoryId ? { category_id: categoryId } : {}), + ...(search ? { + OR: [ + { title: { path: ['th'], string_contains: search } }, + { title: { path: ['en'], string_contains: search } } + ] + } : {}) + }, orderBy: [ { is_recommended: 'desc' }, { updated_at: 'desc' } @@ -40,9 +55,9 @@ export class RecommendedCoursesService { } } }, - chapters: { - include: { - lessons: true + _count: { + select: { + chapters: true } } } @@ -81,8 +96,7 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course.chapters.length, - lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + chapters_count: course._count.chapters, } as RecommendedCourseData; })); @@ -158,7 +172,7 @@ export class RecommendedCoursesService { } } - const data: RecommendedCourseData = { + const data: RecommendedCourseDetailData = { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, @@ -181,8 +195,15 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course.chapters.length, - lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }; return { diff --git a/Backend/src/services/audit.service.ts b/Backend/src/services/audit.service.ts index e913be7b..52038129 100644 --- a/Backend/src/services/audit.service.ts +++ b/Backend/src/services/audit.service.ts @@ -37,19 +37,23 @@ export class AuditService { * Log พร้อม await (สำหรับ critical actions) */ async logSync(params: CreateAuditLogParams): Promise { - await prisma.auditLog.create({ - data: { - user_id: params.userId, - action: params.action, - entity_type: params.entityType, - entity_id: params.entityId, - old_value: params.oldValue, - new_value: params.newValue, - ip_address: params.ipAddress, - user_agent: params.userAgent, - metadata: params.metadata, - }, - }); + try { + await prisma.auditLog.create({ + data: { + user_id: params.userId, + action: params.action, + entity_type: params.entityType, + entity_id: params.entityId, + old_value: params.oldValue, + new_value: params.newValue, + ip_address: params.ipAddress, + user_agent: params.userAgent, + metadata: params.metadata, + }, + }); + } catch (error) { + logger.error('Failed to create audit log (sync)', { error, params }); + } } /** diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index 0700a7fa..6b810ca9 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -103,7 +103,56 @@ export class CoursesService { const course = await prisma.course.findFirst({ where: { id, - status: 'APPROVED' // Only show approved courses to students + status: 'APPROVED' + }, + include: { + creator: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + }, + instructors: { + include: { + user: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + } + } + }, + category: { + select: { id: true, name: true } + }, + chapters: { + orderBy: { sort_order: 'asc' }, + select: { + id: true, + title: true, + sort_order: true, + lessons: { + orderBy: { sort_order: 'asc' }, + select: { id: true, title: true } + } + } + } } }); @@ -124,12 +173,69 @@ export class CoursesService { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } + + // Generate presigned URL for creator avatar + let creator_avatar_url: string | null = null; + if (course.creator.profile?.avatar_url) { + try { + creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`); + } + } + + // Generate presigned URLs for instructor avatars + const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => { + let avatar_url: string | null = null; + if (i.user.profile?.avatar_url) { + try { + avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`); + } + } + return { + user_id: i.user_id, + is_primary: i.is_primary, + user: { + ...i.user, + profile: i.user.profile ? { + ...i.user.profile, + avatar_url + } : null + } + }; + })); + return { code: 200, message: 'Course fetched successfully', data: { ...course, + title: course.title as { th: string; en: string }, + description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, + creator: { + ...course.creator, + profile: course.creator.profile ? { + ...course.creator.profile, + avatar_url: creator_avatar_url + } : null + }, + instructors: instructorsWithAvatar, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }, }; } catch (error) { diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index 918b12e3..69153d51 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -14,7 +14,8 @@ import { updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; @@ -212,6 +213,30 @@ export class UserService { } } + async getRoles(token: string): Promise { + try { + jwt.verify(token, config.jwt.secret); + const roles = await prisma.role.findMany({ + select: { + id: true, + code: true + } + }); + 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; + } + } + /** * Upload avatar picture to MinIO */ diff --git a/Backend/src/types/AdminCourseApproval.types.ts b/Backend/src/types/AdminCourseApproval.types.ts index fa98ea6b..d68c8c81 100644 --- a/Backend/src/types/AdminCourseApproval.types.ts +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -117,10 +117,6 @@ export interface GetCourseDetailForAdminResponse { data: CourseDetailForAdmin; } -export interface ApproveCourseBody { - comment?: string; -} - export interface ApproveCourseResponse { code: number; message: string; diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index a10dac44..cc4aa149 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -23,6 +23,11 @@ export interface createCourseResponse { data: Course; } +export interface ListMyCoursesInput { + token: string; + status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; +} + export interface ListMyCourseResponse { code: number; message: string; @@ -428,3 +433,18 @@ export interface GetCourseApprovalHistoryResponse { approval_history: ApprovalHistoryItem[]; }; } + +export interface CloneCourseInput { + token: string; + course_id: number; + title: MultiLanguageText; +} + +export interface CloneCourseResponse { + code: number; + message: string; + data: { + id: number; + title: MultiLanguageText; + }; +} diff --git a/Backend/src/types/RecommendedCourses.types.ts b/Backend/src/types/RecommendedCourses.types.ts index c11c6c93..48f495a7 100644 --- a/Backend/src/types/RecommendedCourses.types.ts +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -1,14 +1,10 @@ import { MultiLanguageText } from './index'; -// ============================================ -// Request Types -// ============================================ - - // ============================================ // Response Types // ============================================ +/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */ export interface RecommendedCourseData { id: number; title: MultiLanguageText; @@ -41,7 +37,19 @@ export interface RecommendedCourseData { }; }>; chapters_count: number; - lessons_count: number; +} + +/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */ +export interface RecommendedCourseDetailData extends Omit { + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; } export interface ListApprovedCoursesResponse { @@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse { export interface GetCourseByIdResponse { code: number; message: string; - data: RecommendedCourseData; + data: RecommendedCourseDetailData; } export interface ToggleRecommendedResponse { diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index 42c83398..a294d7e2 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -1,4 +1,5 @@ import { Course } from '@prisma/client'; +import { MultiLanguageText } from './index'; export interface ListCoursesInput { category_id?: number; @@ -18,8 +19,47 @@ export interface listCourseResponse { totalPages: number; } +export interface CourseDetail extends Omit { + title: MultiLanguageText; + description: MultiLanguageText; + creator: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + }[]; + category: { id: number; name: MultiLanguageText } | null; + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; +} + export interface getCourseResponse { code: number; message: string; - data: Course | null; + data: CourseDetail | null; } diff --git a/Backend/src/types/user.types.ts b/Backend/src/types/user.types.ts index 413cb3f2..42ac8e75 100644 --- a/Backend/src/types/user.types.ts +++ b/Backend/src/types/user.types.ts @@ -59,6 +59,14 @@ export interface ProfileUpdateResponse { }; }; +export interface role { + id: number; + code: string; +} + +export interface rolesResponse { + roles: role[]; +} export interface ChangePasswordRequest { old_password: string; diff --git a/Backend/src/validators/AdminCourseApproval.validator.ts b/Backend/src/validators/AdminCourseApproval.validator.ts new file mode 100644 index 00000000..89e0a284 --- /dev/null +++ b/Backend/src/validators/AdminCourseApproval.validator.ts @@ -0,0 +1,30 @@ +import Joi from 'joi'; + +/** + * Validator for approving a course + * Comment is optional + */ +export const ApproveCourseValidator = Joi.object({ + comment: Joi.string() + .max(1000) + .optional() + .messages({ + 'string.max': 'Comment must not exceed 1000 characters' + }) +}); + +/** + * Validator for rejecting a course + * Comment is required when rejecting + */ +export const RejectCourseValidator = Joi.object({ + comment: Joi.string() + .min(10) + .max(1000) + .required() + .messages({ + 'string.min': 'Comment must be at least 10 characters when rejecting a course', + 'string.max': 'Comment must not exceed 1000 characters', + 'any.required': 'Comment is required when rejecting a course' + }) +}); diff --git a/Backend/src/validators/ChaptersLesson.validator.ts b/Backend/src/validators/ChaptersLesson.validator.ts new file mode 100644 index 00000000..45d7687a --- /dev/null +++ b/Backend/src/validators/ChaptersLesson.validator.ts @@ -0,0 +1,186 @@ +import Joi from 'joi'; + +// Multi-language validation schema +const multiLangSchema = Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai text is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English text is required' + }) +}).required(); + +const multiLangOptionalSchema = Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() +}).optional(); + +// ============================================ +// Chapter Validators +// ============================================ + +/** + * Validator for creating a chapter + */ +export const CreateChapterValidator = Joi.object({ + title: multiLangSchema.messages({ + 'any.required': 'Title is required' + }), + description: multiLangOptionalSchema, + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for updating a chapter + */ +export const UpdateChapterValidator = Joi.object({ + title: multiLangOptionalSchema, + description: multiLangOptionalSchema, + sort_order: Joi.number().integer().min(0).optional(), + is_published: Joi.boolean().optional() +}); + +/** + * Validator for reordering a chapter + */ +export const ReorderChapterValidator = Joi.object({ + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required', + 'number.min': 'Sort order must be at least 0' + }) +}); + +// ============================================ +// Lesson Validators +// ============================================ + +/** + * Validator for creating a lesson + */ +export const CreateLessonValidator = Joi.object({ + title: multiLangSchema.messages({ + 'any.required': 'Title is required' + }), + content: multiLangOptionalSchema, + type: Joi.string().valid('VIDEO', 'QUIZ').required().messages({ + 'any.only': 'Type must be either VIDEO or QUIZ', + 'any.required': 'Type is required' + }), + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for updating a lesson + */ +export const UpdateLessonValidator = Joi.object({ + title: multiLangOptionalSchema, + content: multiLangOptionalSchema, + duration_minutes: Joi.number().min(0).optional().messages({ + 'number.min': 'Duration must be at least 0' + }), + sort_order: Joi.number().integer().min(0).optional(), + prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).allow(null).optional(), + is_published: Joi.boolean().optional() +}); + +/** + * Validator for reordering lessons + */ +export const ReorderLessonsValidator = Joi.object({ + lesson_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Lesson ID is required' + }), + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required' + }) +}); + +// ============================================ +// Quiz Question Validators +// ============================================ + +/** + * Validator for quiz choice + */ +const QuizChoiceValidator = Joi.object({ + text: multiLangSchema.messages({ + 'any.required': 'Choice text is required' + }), + is_correct: Joi.boolean().required().messages({ + 'any.required': 'is_correct is required' + }), + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for adding a question to a quiz + */ +export const AddQuestionValidator = Joi.object({ + question: multiLangSchema.messages({ + 'any.required': 'Question is required' + }), + explanation: multiLangOptionalSchema, + question_type: Joi.string() + .valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER') + .required() + .messages({ + 'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER', + 'any.required': 'Question type is required' + }), + sort_order: Joi.number().integer().min(0).optional(), + choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({ + 'array.min': 'At least one choice is required for multiple choice questions' + }) +}); + +/** + * Validator for updating a question + */ +export const UpdateQuestionValidator = Joi.object({ + question: multiLangOptionalSchema, + explanation: multiLangOptionalSchema, + question_type: Joi.string() + .valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER') + .optional() + .messages({ + 'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER' + }), + sort_order: Joi.number().integer().min(0).optional(), + choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({ + 'array.min': 'At least one choice is required' + }) +}); + +/** + * Validator for reordering a question + */ +export const ReorderQuestionValidator = Joi.object({ + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required', + 'number.min': 'Sort order must be at least 0' + }) +}); + +// ============================================ +// Quiz Settings Validator +// ============================================ + +/** + * Validator for updating quiz settings + */ +export const UpdateQuizValidator = Joi.object({ + title: multiLangOptionalSchema, + description: multiLangOptionalSchema, + passing_score: Joi.number().min(0).max(100).optional().messages({ + 'number.min': 'Passing score must be at least 0', + 'number.max': 'Passing score must not exceed 100' + }), + time_limit: Joi.number().min(0).optional().messages({ + 'number.min': 'Time limit must be at least 0' + }), + shuffle_questions: Joi.boolean().optional(), + shuffle_choices: Joi.boolean().optional(), + show_answers_after_completion: Joi.boolean().optional(), + is_skippable: Joi.boolean().optional(), + allow_multiple_attempts: Joi.boolean().optional() +}); diff --git a/Backend/src/validators/CoursesInstructor.validator.ts b/Backend/src/validators/CoursesInstructor.validator.ts index fe971950..cbde5802 100644 --- a/Backend/src/validators/CoursesInstructor.validator.ts +++ b/Backend/src/validators/CoursesInstructor.validator.ts @@ -20,3 +20,38 @@ export const CreateCourseValidator = Joi.object({ is_free: Joi.boolean().required(), have_certificate: Joi.boolean().required(), }); + +/** + * Validator for updating a course + */ +export const UpdateCourseValidator = Joi.object({ + category_id: Joi.number().optional(), + title: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional(), + }).optional(), + slug: Joi.string().optional(), + description: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional(), + }).optional(), + price: Joi.number().optional(), + is_free: Joi.boolean().optional(), + have_certificate: Joi.boolean().optional(), +}); + +/** + * Validator for cloning a course + */ +export const CloneCourseValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai title is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English title is required' + }) + }).required().messages({ + 'any.required': 'Title is required' + }) +}); diff --git a/Backend/src/validators/CoursesStudent.validator.ts b/Backend/src/validators/CoursesStudent.validator.ts new file mode 100644 index 00000000..424c35fe --- /dev/null +++ b/Backend/src/validators/CoursesStudent.validator.ts @@ -0,0 +1,38 @@ +import Joi from 'joi'; + +/** + * Validator for saving video progress + */ +export const SaveVideoProgressValidator = Joi.object({ + video_progress_seconds: Joi.number().min(0).required().messages({ + 'any.required': 'Video progress seconds is required', + 'number.min': 'Video progress must be at least 0' + }), + video_duration_seconds: Joi.number().min(0).optional().messages({ + 'number.min': 'Video duration must be at least 0' + }) +}); + +/** + * Validator for quiz answer + */ +const QuizAnswerValidator = Joi.object({ + question_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Question ID is required', + 'number.positive': 'Question ID must be positive' + }), + choice_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Choice ID is required', + 'number.positive': 'Choice ID must be positive' + }) +}); + +/** + * Validator for submitting quiz answers + */ +export const SubmitQuizValidator = Joi.object({ + answers: Joi.array().items(QuizAnswerValidator).min(1).required().messages({ + 'any.required': 'Answers are required', + 'array.min': 'At least one answer is required' + }) +}); diff --git a/Backend/src/validators/Lessons.validator.ts b/Backend/src/validators/Lessons.validator.ts new file mode 100644 index 00000000..4161ec53 --- /dev/null +++ b/Backend/src/validators/Lessons.validator.ts @@ -0,0 +1,15 @@ +import Joi from 'joi'; + +/** + * Validator for setting YouTube video + */ +export const SetYouTubeVideoValidator = Joi.object({ + youtube_video_id: Joi.string().required().messages({ + 'any.required': 'YouTube video ID is required', + 'string.empty': 'YouTube video ID cannot be empty' + }), + video_title: Joi.string().required().messages({ + 'any.required': 'Video title is required', + 'string.empty': 'Video title cannot be empty' + }) +}); diff --git a/Backend/src/validators/announcements.validator.ts b/Backend/src/validators/announcements.validator.ts new file mode 100644 index 00000000..bd9ad945 --- /dev/null +++ b/Backend/src/validators/announcements.validator.ts @@ -0,0 +1,72 @@ +import Joi from 'joi'; + +/** + * Validator for creating an announcement + */ +export const CreateAnnouncementValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai title is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English title is required' + }) + }).required().messages({ + 'any.required': 'Title is required' + }), + content: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai content is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English content is required' + }) + }).required().messages({ + 'any.required': 'Content is required' + }), + status: Joi.string() + .valid('DRAFT', 'PUBLISHED', 'ARCHIVED') + .required() + .messages({ + 'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED', + 'any.required': 'Status is required' + }), + is_pinned: Joi.boolean() + .required() + .messages({ + 'any.required': 'is_pinned is required' + }), + published_at: Joi.string() + .isoDate() + .optional() + .messages({ + 'string.isoDate': 'published_at must be a valid ISO date string' + }) +}); + +/** + * Validator for updating an announcement + */ +export const UpdateAnnouncementValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + content: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + status: Joi.string() + .valid('DRAFT', 'PUBLISHED', 'ARCHIVED') + .optional() + .messages({ + 'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED' + }), + is_pinned: Joi.boolean().optional(), + published_at: Joi.string() + .isoDate() + .optional() + .messages({ + 'string.isoDate': 'published_at must be a valid ISO date string' + }) +}); diff --git a/Backend/src/validators/categories.validator.ts b/Backend/src/validators/categories.validator.ts new file mode 100644 index 00000000..521c9faf --- /dev/null +++ b/Backend/src/validators/categories.validator.ts @@ -0,0 +1,58 @@ +import Joi from 'joi'; + +/** + * Validator for creating a category + */ +export const CreateCategoryValidator = Joi.object({ + name: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai name is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English name is required' + }) + }).required().messages({ + 'any.required': 'Name is required' + }), + slug: Joi.string() + .pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .required() + .messages({ + 'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)', + 'any.required': 'Slug is required' + }), + description: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai description is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English description is required' + }) + }).required().messages({ + 'any.required': 'Description is required' + }), + created_by: Joi.number().optional() +}); + +/** + * Validator for updating a category + */ +export const UpdateCategoryValidator = Joi.object({ + id: Joi.number().required().messages({ + 'any.required': 'Category ID is required' + }), + name: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + slug: Joi.string() + .pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .optional() + .messages({ + 'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)' + }), + description: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional() +}); diff --git a/Backend/tests/k6/enroll-load-test.js b/Backend/tests/k6/enroll-load-test.js new file mode 100644 index 00000000..d3e1032b --- /dev/null +++ b/Backend/tests/k6/enroll-load-test.js @@ -0,0 +1,160 @@ +// Backend/tests/k6/enroll-load-test.js +// +// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน +// +// Flow: +// 1. Login +// 2. Enroll คอร์ส +// 3. ตรวจสอบ enrolled courses +// +// Usage: +// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-load-test.js + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; + +// ─── Custom Metrics ─────────────────────────────────────────────────────────── +const errorRate = new Rate('errors'); +const loginTime = new Trend('login_duration', true); +const enrollTime = new Trend('enroll_duration', true); +const enrolledCount = new Counter('successful_enrollments'); + +// ─── Load student credentials ───────────────────────────────────────────────── +const students = new SharedArray('students', function () { + return JSON.parse(open('./test-credentials.json')).students; +}); + +// ─── Config ─────────────────────────────────────────────────────────────────── +const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; +const COURSE_ID = __ENV.COURSE_ID || '1'; + +// ─── Test Options ───────────────────────────────────────────────────────────── +export const options = { + stages: [ + { duration: '20s', target: 10 }, // Ramp up + { duration: '1m', target: 30 }, // Increase + { duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + 'login_duration': ['p(95)<2000'], // Login < 2s + 'enroll_duration': ['p(95)<1000'], // Enroll < 1s + 'errors': ['rate<0.05'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +export default function () { + const student = students[__VU % students.length]; + let token = null; + + // ── Step 1: Login ────────────────────────────────────────────────────────── + group('1. Login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: student.email, password: student.password }), + { headers: jsonHeaders(null) } + ); + + loginTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + + check(res, { + 'login: status 200': (r) => r.status === 200, + 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, + }); + + if (res.status === 200) { + try { token = res.json('data.token'); } catch {} + } + }); + + if (!token) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(1); + return; + } + + sleep(0.5); + + // ── Step 2: Enroll ───────────────────────────────────────────────────────── + group('2. Enroll Course', () => { + const res = http.post( + `${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`, + null, + { headers: jsonHeaders(token) } + ); + + enrollTime.add(res.timings.duration); + + // 200 = enrolled, 409 = already enrolled (ถือว่าโอเค) + const ok = res.status === 200 || res.status === 409; + errorRate.add(!ok); + + if (res.status === 200) enrolledCount.add(1); + + check(res, { + 'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409, + 'enroll: fast response': (r) => r.timings.duration < 1000, + }); + }); + + sleep(0.5); + + // ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ───────────────────────── + group('3. Get Enrolled Courses', () => { + const res = http.get( + `${BASE_URL}/api/students/courses`, + { headers: jsonHeaders(token) } + ); + + errorRate.add(res.status !== 200); + + check(res, { + 'enrolled courses: status 200': (r) => r.status === 200, + }); + }); + + sleep(1); +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +export function handleSummary(data) { + const m = data.metrics; + const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; + const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; + const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); + const cnt = (k) => m[k]?.values?.count ?? 0; + + return { + stdout: ` +╔══════════════════════════════════════════════════════════╗ +║ Course Enroll — Load Test ║ +╠══════════════════════════════════════════════════════════╣ +║ Course ID : ${String(COURSE_ID).padEnd(43)}║ +╠══════════════════════════════════════════════════════════╣ +║ RESPONSE TIMES (avg / p95) ║ +║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms +║ Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms +╠══════════════════════════════════════════════════════════╣ +║ COUNTS ║ +║ Total Requests : ${String(cnt('http_reqs')).padEnd(33)}║ +║ New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}║ +╠══════════════════════════════════════════════════════════╣ +║ ERROR RATES ║ +║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}║ +║ Custom Errors : ${(rate('errors') + '%').padEnd(39)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} diff --git a/Backend/tests/k6/login-load-test.js b/Backend/tests/k6/login-load-test.js index 2a0c375d..aee4cb4a 100644 --- a/Backend/tests/k6/login-load-test.js +++ b/Backend/tests/k6/login-load-test.js @@ -31,7 +31,7 @@ export const options = { thresholds: { http_req_duration: ['p(95)<2000'], // 95% of requests < 2s errors: ['rate<0.1'], // Error rate < 10% - login_duration: ['p(95)<2000'], // 95% of logins < 2s + login_duration: ['p(95)<2000'], // 95% pof logins < 2s }, }; diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js new file mode 100644 index 00000000..e3bb205c --- /dev/null +++ b/Backend/tests/k6/video-watching-load-test.js @@ -0,0 +1,269 @@ +// Backend/tests/k6/video-watching-load-test.js +// +// จำลองนักเรียนหลายคนดูวีดีโอพร้อมกัน (Concurrent Video Watching) +// +// Flow จริงที่ simulate: +// 1. Login ด้วย account ของ student แต่ละคน +// 2. Load หน้าเรียนคอร์ส (getCourseLearning) +// 3. เปิดบทเรียนวีดีโอ (getLessonContent) +// 4. Save progress ทุก 5 วินาที (จำลองการ watch) +// 5. เมื่อดูครบ (≥90%) → mark lesson complete +// +// Usage: +// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 tests/k6/video-watching-load-test.js +// +// ปรับจำนวน VUs และ duration ได้ด้วย: +// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 --vus 30 --duration 2m tests/k6/video-watching-load-test.js + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; + +// ─── Custom Metrics ─────────────────────────────────────────────────────────── +const errorRate = new Rate('errors'); +const loginTime = new Trend('login_duration', true); +const courseLearningTime = new Trend('course_learning_duration', true); +const lessonLoadTime = new Trend('lesson_load_duration', true); +const progressSaveTime = new Trend('progress_save_duration', true); +const completeLessonTime = new Trend('complete_lesson_duration', true); +const completedCount = new Counter('completed_lessons'); +const progressSaveCount = new Counter('progress_saves'); +const videoLoadTime = new Trend('video_load_duration', true); + +// ─── Load student credentials ──────────────────────────────────────────────── +// อ่านจาก test-credentials.json (50 accounts) +const students = new SharedArray('students', function () { + return JSON.parse(open('./test-credentials.json')).students; +}); + +// ─── Config ─────────────────────────────────────────────────────────────────── +const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; +const COURSE_ID = __ENV.COURSE_ID || '1'; +const LESSON_ID = __ENV.LESSON_ID || '1'; + +// วีดีโอความยาว (วินาที) — ปรับตามจริง +const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที + +// save progress interval: ทุก 5 วินาที (เหมือน client จริง) +// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง +const PROGRESS_INTERVAL_SECONDS = parseInt(__ENV.PROGRESS_INTERVAL || '15'); + +// ─── Test Options ───────────────────────────────────────────────────────────── +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up: 10 คนเริ่มดูวีดีโอ + { duration: '1m', target: 30 }, // Ramp up: เพิ่มเป็น 30 คน + { duration: '2m', target: 30 }, // Steady: 30 คนดูพร้อมกัน + { duration: '30s', target: 50 }, // Peak: เพิ่มเป็น 50 คน + { duration: '1m', target: 50 }, // Steady Peak: 50 คนพร้อมกัน + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + // Response times + 'login_duration': ['p(95)<2000'], // Login < 2s + 'course_learning_duration': ['p(95)<1000'], // Load course page < 1s + 'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s + 'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s + 'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย) + 'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s + + // Error rate + 'errors': ['rate<0.05'], // Error < 5% + 'http_req_failed': ['rate<0.05'], // HTTP error < 5% + }, +}; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +// ─── Per-VU persistent state (จำข้ามรอบ iteration) ────────────────────────── +// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU +// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration) +let vuToken = null; // token ที่ login ไว้แล้ว +let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง +let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที) +let vuCompleted = false; // lesson complete แล้วหรือยัง + +// ─── Main ───────────────────────────────────────────────────────────────────── +export default function () { + const student = students[__VU % students.length]; + + // ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ───────── + if (!vuToken) { + group('1. Login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: student.email, password: student.password }), + { headers: jsonHeaders(null) } + ); + + loginTime.add(res.timings.duration); + const ok = res.status === 200; + errorRate.add(!ok); + + check(res, { + 'login: status 200': (r) => r.status === 200, + 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, + }); + + if (ok) { + try { vuToken = res.json('data.token'); } catch {} + } + }); + + if (!vuToken) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(2); + return; + } + } + + // ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ───────── + + // ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ───── + if (!vuSetupDone) { + group('3. Load Course Learning Page', () => { + const res = http.get( + `${BASE_URL}/api/students/courses/${COURSE_ID}/learn`, + { headers: jsonHeaders(vuToken) } + ); + courseLearningTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'course learn: status 200': (r) => r.status === 200 }); + }); + + sleep(1); + + let videoUrl = null; + group('4. Open Lesson', () => { + const res = http.get( + `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`, + { headers: jsonHeaders(vuToken) } + ); + lessonLoadTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'lesson: status 200': (r) => r.status === 200 }); + if (res.status === 200) { + try { videoUrl = res.json('data.video_url'); } catch {} + } + }); + + // ── Step 4.5: Fetch video จาก MinIO ────────────────────────────────────── + if (videoUrl) { + group('4.5 Fetch Video from MinIO', () => { + const res = http.get(videoUrl, { + headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก + timeout: '10s', + }); + videoLoadTime.add(res.timings.duration); + const ok = res.status === 200 || res.status === 206; + errorRate.add(!ok); + check(res, { + 'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206, + 'minio video: fast': (r) => r.timings.duration < 3000, + }); + }); + } else { + console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`); + } + + sleep(2); // รอ buffer เริ่มต้น + vuSetupDone = true; + } + + // ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ──────────────────── + // แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง + if (!vuCompleted) { + vuProgress += PROGRESS_INTERVAL_SECONDS; + + group('5. Watch Video (Save Progress)', () => { + const res = http.post( + `${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`, + JSON.stringify({ + video_progress_seconds: vuProgress, + video_duration_seconds: VIDEO_DURATION_SECONDS, + }), + { headers: jsonHeaders(vuToken) } + ); + + progressSaveTime.add(res.timings.duration); + progressSaveCount.add(1); + + const ok = res.status === 200; + errorRate.add(!ok); + check(res, { + 'progress save: status 200': (r) => r.status === 200, + 'progress save: fast': (r) => r.timings.duration < 500, + }); + + console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`); + }); + + // ── Step 6: Mark complete เมื่อดูครบ ≥95% ────────────────────────────── + if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) { + group('6. Complete Lesson', () => { + const res = http.post( + `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`, + null, + { headers: jsonHeaders(vuToken) } + ); + completeLessonTime.add(res.timings.duration); + errorRate.add(res.status !== 200 && res.status !== 409); + if (res.status === 200) completedCount.add(1); + check(res, { + 'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409, + }); + }); + + vuCompleted = true; + console.log(`[VU ${__VU}] ✓ Lesson completed`); + } + } + + // sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที + sleep(PROGRESS_INTERVAL_SECONDS); +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +export function handleSummary(data) { + const m = data.metrics; + + const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; + const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; + const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); + const count = (k) => m[k]?.values?.count ?? 0; + + return { + stdout: ` +╔══════════════════════════════════════════════════════════╗ +║ Concurrent Video Watching — Load Test ║ +╠══════════════════════════════════════════════════════════╣ +║ Course ID : ${COURSE_ID.padEnd(44)}║ +║ Lesson ID : ${LESSON_ID.padEnd(44)}║ +║ Video : ${String(VIDEO_DURATION_SECONDS + 's').padEnd(44)}║ +╠══════════════════════════════════════════════════════════╣ +║ RESPONSE TIMES (avg / p95) ║ +║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║ +║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║ +║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║ +║ MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}║ +║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║ +║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║ +╠══════════════════════════════════════════════════════════╣ +║ COUNTS ║ +║ Total Requests : ${String(count('http_reqs')).padEnd(33)}║ +║ Progress Saves : ${String(count('progress_saves')).padEnd(33)}║ +║ Lessons Completed : ${String(count('completed_lessons')).padEnd(33)}║ +╠══════════════════════════════════════════════════════════╣ +║ ERROR RATES ║ +║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(33)}║ +║ Custom Errors : ${(rate('errors') + '%').padEnd(33)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} 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/app.vue b/Frontend-Learner/app.vue index a1ac35c7..1dfac678 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -1,27 +1,34 @@ - @@ -181,6 +360,17 @@ const courses = ref([]); const loading = ref(true); const searchQuery = ref(''); const filterStatus = ref(null); +const viewMode = ref<'card' | 'table'>('card'); + +// Table config +const tablePagination = ref({ page: 1, rowsPerPage: 10 }); +const tableColumns = [ + { name: 'title', label: 'หลักสูตร', field: 'title', align: 'left' as const, sortable: true }, + { name: 'status', label: 'สถานะ', field: 'status', align: 'center' as const, sortable: true }, + { name: 'price', label: 'ราคา', field: 'price', align: 'center' as const, sortable: true }, + { name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const, sortable: true }, + { name: 'actions', label: 'จัดการ', field: 'actions', align: 'center' as const } +]; // Status options const statusOptions = [ @@ -188,7 +378,8 @@ const statusOptions = [ { label: 'เผยแพร่แล้ว', value: 'APPROVED' }, { label: 'รอตรวจสอบ', value: 'PENDING' }, { label: 'แบบร่าง', value: 'DRAFT' }, - { label: 'ถูกปฏิเสธ', value: 'REJECTED' } + { label: 'ถูกปฏิเสธ', value: 'REJECTED' }, + //{ label: 'เก็บถาวร', value: 'ARCHIVED' } ]; // Stats @@ -196,10 +387,11 @@ const stats = computed(() => ({ total: courses.value.length, approved: courses.value.filter(c => c.status === 'APPROVED').length, pending: courses.value.filter(c => c.status === 'PENDING').length, - draft: courses.value.filter(c => c.status === 'DRAFT').length + draft: courses.value.filter(c => c.status === 'DRAFT').length, + rejected: courses.value.filter(c => c.status === 'REJECTED').length })); -// Filtered courses +// Filtered courses (search only, status is handled server-side) const filteredCourses = computed(() => { let result = courses.value; @@ -211,10 +403,6 @@ const filteredCourses = computed(() => { ); } - if (filterStatus.value) { - result = result.filter(course => course.status === filterStatus.value); - } - return result; }); @@ -222,7 +410,7 @@ const filteredCourses = computed(() => { const fetchCourses = async () => { loading.value = true; try { - courses.value = await instructorService.getCourses(); + courses.value = await instructorService.getCourses(filterStatus.value || undefined); } catch (error) { $q.notify({ type: 'negative', @@ -234,12 +422,18 @@ const fetchCourses = async () => { } }; +// Re-fetch when status filter changes +watch(filterStatus, () => { + fetchCourses(); +}); + const getStatusColor = (status: string) => { const colors: Record = { APPROVED: 'green', PENDING: 'orange', DRAFT: 'grey', - REJECTED: 'red' + REJECTED: 'red', + ARCHIVED: 'blue-grey' }; return colors[status] || 'grey'; }; @@ -249,7 +443,8 @@ const getStatusLabel = (status: string) => { APPROVED: 'เผยแพร่แล้ว', PENDING: 'รอตรวจสอบ', DRAFT: 'แบบร่าง', - REJECTED: 'ถูกปฏิเสธ' + REJECTED: 'ถูกปฏิเสธ', + ARCHIVED: 'เก็บถาวร' }; return labels[status] || status; }; @@ -261,15 +456,45 @@ const formatDate = (date: string) => { year: '2-digit' }); }; +// Clone Dialog +const cloneDialog = ref(false); +const cloneLoading = ref(false); +const cloneCourseTitleTh = ref(''); +const cloneCourseTitleEn = ref(''); +const courseToClone = ref(null); const duplicateCourse = (course: CourseResponse) => { - $q.notify({ - type: 'info', - message: `กำลังทำสำเนา "${course.title.th}"...`, - position: 'top' - }); + courseToClone.value = course; + cloneCourseTitleTh.value = `${course.title.th} (Copy)`; + cloneCourseTitleEn.value = `${course.title.en} (Copy)`; + cloneDialog.value = true; }; +const confirmClone = async () => { + if (!courseToClone.value || !cloneCourseTitleTh.value || !cloneCourseTitleEn.value) return; + + cloneLoading.value = true; + try { + const response = await instructorService.cloneCourse(courseToClone.value.id, cloneCourseTitleTh.value, cloneCourseTitleEn.value); + $q.notify({ + type: 'positive', + message: response.message || 'ทำสำเนาหลักสูตรสำเร็จ', + position: 'top' + }); + cloneDialog.value = false; + fetchCourses(); // Refresh list + } catch (error: any) { + $q.notify({ + type: 'negative', + message: error.data?.message || 'ไม่สามารถทำสำเนาหลักสูตรได้', + position: 'top' + }); + } finally { + cloneLoading.value = false; + } +}; + + const confirmDelete = (course: CourseResponse) => { $q.dialog({ title: 'ยืนยันการลบ', @@ -296,6 +521,41 @@ const confirmDelete = (course: CourseResponse) => { }); }; +// Rejection Dialog +const rejectionDialog = ref(false); +const selectedRejectionCourse = ref(null); + +const handleViewDetails = (course: CourseResponse) => { + if (course.status === 'REJECTED') { + selectedRejectionCourse.value = course; + rejectionDialog.value = true; + } else { + navigateTo(`/instructor/courses/${course.id}`); + } +}; + +const returnToDraft = async () => { + if (!selectedRejectionCourse.value) return; + + try { + const response = await instructorService.setCourseDraft(selectedRejectionCourse.value.id); + $q.notify({ + type: 'positive', + message: response.message || 'คืนสถานะเป็นแบบร่างสำเร็จ', + position: 'top' + }); + rejectionDialog.value = false; + selectedRejectionCourse.value = null; + fetchCourses(); // Refresh list + } catch (error: any) { + $q.notify({ + type: 'negative', + message: error.data?.message || 'ไม่สามารถคืนสถานะได้', + position: 'top' + }); + } +}; + // Lifecycle onMounted(() => { fetchCourses(); diff --git a/frontend_management/pages/instructor/index.vue b/frontend_management/pages/instructor/index.vue index 8b7fb904..16411da0 100644 --- a/frontend_management/pages/instructor/index.vue +++ b/frontend_management/pages/instructor/index.vue @@ -171,6 +171,8 @@