diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b678c0..b6c4fbe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,10 +4,36 @@ on: push: branches: [ "release" ] +env: + NODE_ENV: test + DB_HOST_TEST: localhost + DB_PORT_TEST: 3306 + DB_PASSWD_TEST: test + DB_NAME_TEST: test_db + jobs: deploy: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: test_db + MYSQL_ROOT_HOST: '%' # 모든 호스트에서 접근 허용 + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + steps: - name: Github Repository 파일 불러오기 uses: actions/checkout@v4 @@ -90,9 +116,9 @@ jobs: cd cd /home/ubuntu/nest-server/current/FE npm i - pm2 stop backend-server || true + npx pm2 stop backend-server || true cd /home/ubuntu/nest-server/current/BE - pm2 start dist/main.js --name "backend-server" + npx pm2 start dist/main.js --name "backend-server" # todo: # FrontEnd 배포 # # Frontend 배포 diff --git a/.github/workflows/notify-pr-review.yml b/.github/workflows/notify-pr-review.yml new file mode 100644 index 0000000..99acd07 --- /dev/null +++ b/.github/workflows/notify-pr-review.yml @@ -0,0 +1,15 @@ +name: notify pr review + +on: + pull_request: + types: [ review_requested ] + +jobs: + notify: + runs-on: [ ubuntu-latest ] + steps: + - name: Notify PR Review + uses: naver/notify-pr-review@v1.2.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + slackBotToken: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 723ef36..3bf780b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +.env \ No newline at end of file diff --git a/BE/.gitignore b/BE/.gitignore index 4c9c79f..668f931 100644 --- a/BE/.gitignore +++ b/BE/.gitignore @@ -37,7 +37,7 @@ lerna-debug.log* !.vscode/extensions.json # dotenv environment variable files -.env +../.env .env.development.local .env.test.local .env.production.local diff --git a/BE/.jest-test-results.json b/BE/.jest-test-results.json index b49c7ac..5dda7c4 100644 --- a/BE/.jest-test-results.json +++ b/BE/.jest-test-results.json @@ -1 +1 @@ -{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":2,"numPassedTests":10,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":2,"numTotalTests":10,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1730881811395,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":203,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 유효한 설정으로 게임방 생성 성공","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"유효한 설정으로 게임방 생성 성공"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":105,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 title","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"빈 title"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":68,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 gameMode","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"빈 gameMode"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":85,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 gameMode","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"잘못된 gameMode"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":83,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최소 인원 미달","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"최소 인원 미달"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":79,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최대 인원 초과","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"최대 인원 초과"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":91,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 boolean 타입","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"잘못된 boolean 타입"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":69,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 방생성시 서버는 올바른 6자리 숫자(PIN)을 응답해야한다.","invocations":1,"location":null,"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"방생성시 서버는 올바른 6자리 숫자(PIN)을 응답해야한다."},{"ancestorTitles":["GameGateway (e2e)","chatMessage 이벤트 테스트"],"duration":126,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) chatMessage 이벤트 테스트 같은 Room의 플레이어들에게 브로드캐스팅 성공","invocations":1,"location":null,"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"같은 Room의 플레이어들에게 브로드캐스팅 성공"}],"endTime":1730881821139,"message":"","name":"D:\\nest_project\\QuizGround\\web10-QuizGround\\BE\\test\\game.e2e-spec.ts","startTime":1730881811553,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["AppController (e2e)"],"duration":158,"failureDetails":[],"failureMessages":[],"fullName":"AppController (e2e) / (GET)","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"status":"passed","title":"/ (GET)"}],"endTime":1730881821996,"message":"","name":"D:\\nest_project\\QuizGround\\web10-QuizGround\\BE\\test\\app.e2e-spec.ts","startTime":1730881821176,"status":"passed","summary":""}],"wasInterrupted":false} +{"numFailedTestSuites":1,"numFailedTests":0,"numPassedTestSuites":1,"numPassedTests":10,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":1,"numTodoTests":0,"numTotalTestSuites":2,"numTotalTests":10,"openHandles":[{}],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1731590376451,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":242,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 유효한 설정으로 게임방 생성 성공","invocations":1,"location":null,"numPassingAsserts":6,"retryReasons":[],"status":"passed","title":"유효한 설정으로 게임방 생성 성공"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":93,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 title인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"빈 title인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":80,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 gameMode인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"빈 gameMode인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":87,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 gameMode인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"잘못된 gameMode인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":78,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최소 인원 미달인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"최소 인원 미달인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","joinRoom 이벤트 테스트"],"duration":87,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) joinRoom 이벤트 테스트 존재하는 방 참여 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"존재하는 방 참여 성공"},{"ancestorTitles":["GameGateway (e2e)","joinRoom 이벤트 테스트"],"duration":73,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) joinRoom 이벤트 테스트 존재하지 않는 방 참여 실패","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"존재하지 않는 방 참여 실패"},{"ancestorTitles":["GameGateway (e2e)","chatMessage 이벤트 테스트"],"duration":182,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) chatMessage 이벤트 테스트 같은 방의 모든 플레이어에게 메시지 전송","invocations":1,"location":null,"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"같은 방의 모든 플레이어에게 메시지 전송"},{"ancestorTitles":["GameGateway (e2e)","updatePosition 이벤트 테스트"],"duration":133,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) updatePosition 이벤트 테스트 위치 업데이트 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"위치 업데이트 성공"},{"ancestorTitles":["GameGateway (e2e)","startGame 이벤트 테스트"],"duration":1613,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) startGame 이벤트 테스트 게임 시작 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"게임 시작 성공"}],"endTime":1731590391795,"message":"","name":"/Users/song-build/IdeaProjects/web10-QuizGround/BE/test/game.e2e-spec.ts","startTime":1731590376502,"status":"passed","summary":""},{"assertionResults":[],"coverage":{},"endTime":1731590392117,"message":" \u001b[1m● \u001b[22mTest suite failed to run\n\n Your test suite must contain at least one test.\n\n \u001b[2mat onResult (\u001b[22m../node_modules/@jest/core/build/TestScheduler.js\u001b[2m:133:18)\u001b[22m\n \u001b[2mat \u001b[22m../node_modules/@jest/core/build/TestScheduler.js\u001b[2m:254:19\u001b[22m\n \u001b[2mat \u001b[22m../node_modules/emittery/index.js\u001b[2m:363:13\u001b[22m\n at Array.map ()\n \u001b[2mat Emittery.emit (\u001b[22m../node_modules/emittery/index.js\u001b[2m:361:23)\u001b[22m\n","name":"/Users/song-build/IdeaProjects/web10-QuizGround/BE/test/app.e2e-spec.ts","startTime":1731590392117,"status":"failed","summary":""}],"wasInterrupted":false} diff --git a/BE/package-lock.json b/BE/package-lock.json index 12c5145..2f67d30 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -9,16 +9,27 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.7", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/swagger": "^8.0.5", + "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dotenv": "^16.4.5", + "ioredis": "^5.4.1", + "mysql2": "^3.11.4", + "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "typeorm": "^0.3.20", "uuid": "^11.0.2" }, "devDependencies": { @@ -26,6 +37,7 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.17", + "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/socket.io": "^3.0.2", @@ -35,6 +47,7 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "ioredis-mock": "^8.9.0", "jest": "^29.5.0", "jest-stare": "^2.5.2", "prettier": "^3.0.0", @@ -686,7 +699,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -698,7 +711,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -864,11 +877,21 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@ioredis/as-callback": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz", + "integrity": "sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==", + "dev": true + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -885,7 +908,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "engines": { "node": ">=12" }, @@ -897,7 +919,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -908,14 +929,12 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -932,7 +951,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -947,7 +965,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -1407,7 +1424,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6.0.0" } @@ -1435,7 +1452,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "devOptional": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1473,6 +1490,35 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", + "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==" + }, + "node_modules/@nestjs-modules/ioredis": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", + "integrity": "sha512-8pzSvT8R3XP6p8ZzQmEN8OnY0yWrJ/elFhwQK+PID2zf1SLBkAZ18bDcx3SKQ2atledt0gd9kBeP5xT4MlyS7Q==", + "optionalDependencies": { + "@nestjs/terminus": "10.2.0" + }, + "peerDependencies": { + "@nestjs/common": ">=6.7.0", + "@nestjs/core": ">=6.7.0", + "ioredis": ">=5.0.0" + } + }, + "node_modules/@nestjs/axios": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.2.tgz", + "integrity": "sha512-pFlfi4ZQsZtTNNhvgssbxjCHUd1nMpV3sXy/xOOB2uEJhw3M8j8SFR08gjFNil2we2Har7VCsXLfCkwbMHECFQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -1627,6 +1673,20 @@ } } }, + "node_modules/@nestjs/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", + "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", + "dependencies": { + "dotenv": "16.4.5", + "dotenv-expand": "10.0.0", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "10.4.7", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.7.tgz", @@ -1665,9 +1725,9 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", - "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", + "integrity": "sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==", "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "class-transformer": "^0.4.0 || ^0.5.0", @@ -1800,6 +1860,108 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, + "node_modules/@nestjs/swagger": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.0.5.tgz", + "integrity": "sha512-ZmBdsbQNs3wIN5kCuvAVbz3/ULh3gi814oHTP49uTqAGi1aT0YSatUyncwQOHBOlRT+rwF+TNjoAsZ+twIk/Jw==", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.6", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.18.2" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/terminus": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", + "integrity": "sha512-zPs98xvJ4ogEimRQOz8eU90mb7z+W/kd/mL4peOgrJ/VqER+ibN2Cboj65uJZW3XuNhpOqaeYOJte86InJd44A==", + "optional": true, + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.6", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.6.tgz", @@ -1827,6 +1989,33 @@ } } }, + "node_modules/@nestjs/typeorm": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", + "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@nestjs/typeorm/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/websockets": { "version": "10.4.7", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.7.tgz", @@ -1905,7 +2094,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" @@ -1934,6 +2122,77 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/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/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1963,29 +2222,34 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true + "devOptional": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2111,6 +2375,16 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/ioredis-mock": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/@types/ioredis-mock/-/ioredis-mock-8.2.5.tgz", + "integrity": "sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "ioredis": ">=5" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2633,7 +2907,7 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, + "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -2663,7 +2937,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "dependencies": { "acorn": "^8.11.0" }, @@ -2704,6 +2978,15 @@ } } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "optional": true, + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2750,7 +3033,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2769,6 +3051,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2794,6 +3081,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -2803,13 +3098,12 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true + "devOptional": true }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -2846,8 +3140,26 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/babel-jest": { "version": "29.7.0", @@ -2971,14 +3283,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3094,11 +3404,61 @@ "@popperjs/core": "^2.11.8" } }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "optional": true, + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -3314,6 +3674,15 @@ "pnpm": ">=8" } }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "optional": true, + "engines": { + "node": ">=16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3383,6 +3752,18 @@ "validator": "^13.9.0" } }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "optional": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3395,6 +3776,85 @@ "node": ">=8" } }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "engines": { + "node": "*" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -3435,7 +3895,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3449,7 +3908,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3471,6 +3929,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3507,7 +3974,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3686,13 +4152,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "devOptional": true }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3702,6 +4167,11 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3779,11 +4249,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3824,7 +4301,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.3.1" } @@ -3887,11 +4364,29 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "engines": { + "node": ">=12" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ee-first": { "version": "1.1.1", @@ -3934,8 +4429,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { "version": "2.0.0", @@ -4045,7 +4539,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -4528,6 +5021,32 @@ "bser": "2.1.1" } }, + "node_modules/fengari": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.4.tgz", + "integrity": "sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==", + "dev": true, + "dependencies": { + "readline-sync": "^1.4.9", + "sprintf-js": "^1.1.1", + "tmp": "^0.0.33" + } + }, + "node_modules/fengari-interop": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.3.tgz", + "integrity": "sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==", + "dev": true, + "peerDependencies": { + "fengari": "^0.1.0" + } + }, + "node_modules/fengari/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -4663,11 +5182,30 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -4733,7 +5271,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4797,21 +5334,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "dev": true }, "node_modules/function-bind": { "version": "1.1.2", @@ -4821,6 +5344,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4834,7 +5374,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -4882,7 +5421,6 @@ "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", - "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -5131,7 +5669,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -5242,6 +5779,49 @@ "node": ">=12.0.0" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis-mock": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-8.9.0.tgz", + "integrity": "sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==", + "dev": true, + "dependencies": { + "@ioredis/as-callback": "^3.0.0", + "@ioredis/commands": "^1.2.0", + "fengari": "^0.1.4", + "fengari-interop": "^0.1.3", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12.22" + }, + "peerDependencies": { + "@types/ioredis-mock": "^8", + "ioredis": "^5" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5296,7 +5876,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -5349,6 +5928,11 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5381,8 +5965,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -5471,7 +6054,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -6240,7 +6822,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6392,8 +6973,17 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -6423,6 +7013,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6432,6 +7027,20 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -6463,7 +7072,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "devOptional": true }, "node_modules/makeerror": { "version": "1.0.12", @@ -6593,7 +7202,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6616,7 +7224,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -6678,6 +7285,65 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/mysql2": { + "version": "3.11.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.4.tgz", + "integrity": "sha512-Z2o3tY4Z8EvSRDwknaC40MdZ3+m0sKbpnXrShQLdxPrAvcNli7jLrD2Zd2IzsRMw4eK9Yle500FDmlkIqp+krg==", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6933,8 +7599,7 @@ "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -6966,6 +7631,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6996,7 +7679,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -7011,7 +7693,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -7026,8 +7707,7 @@ "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "node_modules/path-to-regexp": { "version": "3.3.0", @@ -7319,6 +7999,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7458,6 +8144,51 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -7476,7 +8207,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7803,6 +8533,11 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -7847,11 +8582,22 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -7863,7 +8609,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -7889,7 +8634,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -7999,6 +8743,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8020,6 +8772,11 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -8066,7 +8823,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8081,7 +8837,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8095,7 +8850,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8108,7 +8862,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8214,6 +8967,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", + "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -8398,6 +9159,25 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -8540,7 +9320,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8646,7 +9426,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=10" }, @@ -8671,11 +9451,165 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/typeorm": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", + "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "chalk": "^4.1.2", + "cli-highlight": "^2.1.11", + "dayjs": "^1.11.9", + "debug": "^4.3.4", + "dotenv": "^16.0.3", + "glob": "^10.3.10", + "mkdirp": "^2.1.3", + "reflect-metadata": "^0.2.1", + "sha.js": "^2.4.11", + "tslib": "^2.5.0", + "uuid": "^9.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0", + "@sap/hana-client": "^2.12.25", + "better-sqlite3": "^7.1.2 || ^8.0.0 || ^9.0.0", + "hdb-pool": "^0.1.6", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0", + "mssql": "^9.1.1 || ^10.0.1", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "hdb-pool": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/typeorm/node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8785,7 +9719,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true + "devOptional": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -8955,7 +9889,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8966,6 +9899,18 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "optional": true, + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8994,7 +9939,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9073,7 +10017,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -9088,7 +10031,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -9106,7 +10048,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "engines": { "node": ">=12" } @@ -9115,7 +10056,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6" } diff --git a/BE/package.json b/BE/package.json index 1b93b68..8463a55 100644 --- a/BE/package.json +++ b/BE/package.json @@ -24,16 +24,27 @@ "test:e2e:watch": "jest --config ./test/jest-e2e.json --verbose --watchAll" }, "dependencies": { + "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.7", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/swagger": "^8.0.5", + "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dotenv": "^16.4.5", + "ioredis": "^5.4.1", + "mysql2": "^3.11.4", + "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "typeorm": "^0.3.20", "uuid": "^11.0.2" }, "devDependencies": { @@ -41,6 +52,7 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.17", + "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/socket.io": "^3.0.2", @@ -50,6 +62,7 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "ioredis-mock": "^8.9.0", "jest": "^29.5.0", "jest-stare": "^2.5.2", "prettier": "^3.0.0", diff --git a/BE/src/InitDB/InitDB.Service.ts b/BE/src/InitDB/InitDB.Service.ts new file mode 100644 index 0000000..8976572 --- /dev/null +++ b/BE/src/InitDB/InitDB.Service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { QuizSetModel } from '../quiz/entities/quiz-set.entity'; +import { QuizModel } from '../quiz/entities/quiz.entity'; +import { QUIZ_SET_TEST_DATA } from './QUIZ_SET_TEST_DATA'; +import { QuizChoiceModel } from '../quiz/entities/quiz-choice.entity'; +import { UserModel } from '../user/entities/user.entity'; + +/** + * Quiz Set Type Definitions + */ +export interface QuizSetData { + title: string; + category: string; + quizList: QuizData[]; +} + +export interface QuizData { + quiz: string; + limitTime: number; + choiceList: ChoiceData[]; +} + +export interface ChoiceData { + content: string; + order: number; + isAnswer: boolean; +} + +/** + * Seed Service Implementation + */ +@Injectable() +export class InitDBService { + constructor( + @InjectRepository(QuizSetModel) + private readonly quizSetRepository: Repository, + @InjectRepository(QuizModel) + private readonly quizRepository: Repository, + @InjectRepository(QuizChoiceModel) + private readonly choiceRepository: Repository, + @InjectRepository(UserModel) + private readonly userRepository: Repository, + private readonly dataSource: DataSource + ) {} + + async create(): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // 1. Create User + let user = queryRunner.manager.create(UserModel, { + email: 'honux@codesquad.co.kr', + nickname: 'honux', + password: '123456', + status: 'ACTIVE', + point: 100 + }); + const findUser = await this.userRepository.findOne({ + where: { email: user.email } + }); + if (!findUser) { + await this.userRepository.save(user); + } else { + user = findUser; + } + + for (const quizSetData of QUIZ_SET_TEST_DATA) { + // 2. Create QuizSet + const quizSet = queryRunner.manager.create(QuizSetModel, { + user, + title: quizSetData.title, + category: quizSetData.category + }); + await queryRunner.manager.save(quizSet); + + // 3. Create Quizzes + for (const quizData of quizSetData.quizList) { + const quiz = queryRunner.manager.create(QuizModel, { + quizSet, + quiz: quizData.quiz, + limitTime: quizData.limitTime + }); + await queryRunner.manager.save(quiz); + + // 4. Create Choices + const choices = quizData.choiceList.map((choiceData) => + queryRunner.manager.create(QuizChoiceModel, { + quiz, + choiceContent: choiceData.content, + choiceOrder: choiceData.order, + isAnswer: choiceData.isAnswer + }) + ); + await queryRunner.manager.save(choices); + } + } + + await queryRunner.commitTransaction(); + } catch (error) { + console.error('Seeding failed:', error); + await queryRunner.rollbackTransaction(); + throw new Error(`Failed to seed database: ${error.message}`); + } finally { + await queryRunner.release(); + } + } +} diff --git a/BE/src/InitDB/InitDB.controller.ts b/BE/src/InitDB/InitDB.controller.ts new file mode 100644 index 0000000..d32e92c --- /dev/null +++ b/BE/src/InitDB/InitDB.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Post } from '@nestjs/common'; +import { InitDBService } from './InitDB.Service'; + +@Controller('/api/initDB') +export class InitDBController { + constructor(private readonly initDBService: InitDBService) {} + + @Post() + create() { + return this.initDBService.create(); + } +} diff --git a/BE/src/InitDB/InitDB.module.ts b/BE/src/InitDB/InitDB.module.ts new file mode 100644 index 0000000..8564cd1 --- /dev/null +++ b/BE/src/InitDB/InitDB.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserModel } from '../user/entities/user.entity'; +import { UserQuizArchiveModel } from '../user/entities/user-quiz-archive.entity'; +import { InitDBService } from './InitDB.Service'; +import { QuizModel } from '../quiz/entities/quiz.entity'; +import { QuizSetModel } from '../quiz/entities/quiz-set.entity'; +import { InitDBController } from './InitDB.controller'; +import { QuizChoiceModel } from '../quiz/entities/quiz-choice.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + UserModel, + QuizModel, + QuizSetModel, + QuizChoiceModel, + UserQuizArchiveModel + ]) + ], + controllers: [InitDBController], + providers: [InitDBService], + exports: [InitDBService] +}) +export class InitDBModule {} diff --git a/BE/src/InitDB/QUIZ_SET_TEST_DATA.ts b/BE/src/InitDB/QUIZ_SET_TEST_DATA.ts new file mode 100644 index 0000000..b45b769 --- /dev/null +++ b/BE/src/InitDB/QUIZ_SET_TEST_DATA.ts @@ -0,0 +1,292 @@ +export const QUIZ_SET_TEST_DATA = [ + { + title: '재미있는 상식 퀴즈', + category: 'GENERAL', + quizList: [ + { + quiz: '다음 중 대한민국의 수도는?', + limitTime: 30, + choiceList: [ + { + content: '서울', + order: 1, + isAnswer: true + }, + { + content: '부산', + order: 2, + isAnswer: false + }, + { + content: '인천', + order: 3, + isAnswer: false + }, + { + content: '대구', + order: 4, + isAnswer: false + } + ] + }, + { + quiz: '1년은 몇 개월인가요?', + limitTime: 20, + choiceList: [ + { + content: '10개월', + order: 1, + isAnswer: false + }, + { + content: '11개월', + order: 2, + isAnswer: false + }, + { + content: '12개월', + order: 3, + isAnswer: true + }, + { + content: '13개월', + order: 4, + isAnswer: false + } + ] + } + ] + }, + { + title: 'IT 기초 지식 테스트', + category: 'IT', + quizList: [ + { + quiz: 'HTML은 무엇의 약자인가요?', + limitTime: 45, + choiceList: [ + { + content: 'Hyper Text Markup Language', + order: 1, + isAnswer: true + }, + { + content: 'High Tech Modern Language', + order: 2, + isAnswer: false + }, + { + content: 'Hyper Transfer Markup Language', + order: 3, + isAnswer: false + }, + { + content: 'High Text Modern Language', + order: 4, + isAnswer: false + } + ] + }, + { + quiz: '다음 중 프론트엔드 프레임워크가 아닌 것은?', + limitTime: 40, + choiceList: [ + { + content: 'React', + order: 1, + isAnswer: false + }, + { + content: 'Django', + order: 2, + isAnswer: true + }, + { + content: 'Vue', + order: 3, + isAnswer: false + }, + { + content: 'Angular', + order: 4, + isAnswer: false + } + ] + } + ] + }, + { + title: '역사 퀴즈', + category: 'HISTORY', + quizList: [ + { + quiz: '세종대왕이 훈민정음을 반포한 연도는?', + limitTime: 30, + choiceList: [ + { + content: '1443년', + order: 1, + isAnswer: false + }, + { + content: '1444년', + order: 2, + isAnswer: false + }, + { + content: '1445년', + order: 3, + isAnswer: false + }, + { + content: '1446년', + order: 4, + isAnswer: true + } + ] + }, + { + quiz: '임진왜란이 일어난 연도는?', + limitTime: 30, + choiceList: [ + { + content: '1592년', + order: 1, + isAnswer: true + }, + { + content: '1596년', + order: 2, + isAnswer: false + }, + { + content: '1598년', + order: 3, + isAnswer: false + }, + { + content: '1600년', + order: 4, + isAnswer: false + } + ] + } + ] + }, + { + title: '영어 문법 테스트', + category: 'ENGLISH', + quizList: [ + { + quiz: '다음 중 현재완료 시제는?', + limitTime: 40, + choiceList: [ + { + content: 'I am going', + order: 1, + isAnswer: false + }, + { + content: 'I have gone', + order: 2, + isAnswer: true + }, + { + content: 'I will go', + order: 3, + isAnswer: false + }, + { + content: 'I went', + order: 4, + isAnswer: false + } + ] + }, + { + quiz: '다음 중 관계대명사가 아닌 것은?', + limitTime: 35, + choiceList: [ + { + content: 'which', + order: 1, + isAnswer: false + }, + { + content: 'when', + order: 2, + isAnswer: false + }, + { + content: 'how', + order: 3, + isAnswer: true + }, + { + content: 'that', + order: 4, + isAnswer: false + } + ] + } + ] + }, + { + title: '과학 상식', + category: 'SCIENCE', + quizList: [ + { + quiz: '물의 화학식은?', + limitTime: 25, + choiceList: [ + { + content: 'H2O', + order: 1, + isAnswer: true + }, + { + content: 'CO2', + order: 2, + isAnswer: false + }, + { + content: 'O2', + order: 3, + isAnswer: false + }, + { + content: 'H2O2', + order: 4, + isAnswer: false + } + ] + }, + { + quiz: '태양계에서 가장 큰 행성은?', + limitTime: 30, + choiceList: [ + { + content: '화성', + order: 1, + isAnswer: false + }, + { + content: '목성', + order: 2, + isAnswer: true + }, + { + content: '토성', + order: 3, + isAnswer: false + }, + { + content: '천왕성', + order: 4, + isAnswer: false + } + ] + } + ] + } +]; diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 0a0ebb4..d9d71f9 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -2,9 +2,49 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { GameModule } from './game/game.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigModule } from '@nestjs/config'; +import { QuizSetModel } from './quiz/entities/quiz-set.entity'; +import { QuizModel } from './quiz/entities/quiz.entity'; +import { QuizChoiceModel } from './quiz/entities/quiz-choice.entity'; +import { UserModel } from './user/entities/user.entity'; +import { UserQuizArchiveModel } from './user/entities/user-quiz-archive.entity'; +import { InitDBModule } from './InitDB/InitDB.module'; +import { UserModule } from './user/user.module'; +import { QuizModule } from './quiz/quiz.module'; @Module({ - imports: [GameModule], + imports: [ + ConfigModule.forRoot({ + envFilePath: '../.env', + isGlobal: true + }), + GameModule, + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: +process.env.DB_PORT || 3306, + username: process.env.DB_USER || 'root', + password: process.env.DB_PASSWD || 'test', + database: process.env.DB_NAME || 'test_db', + entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], + synchronize: process.env.DEV ? true : false, // 개발 모드에서만 활성화 + logging: true, // 모든 쿼리 로깅 + logger: 'advanced-console' + // extra: { + // // 글로벌 batch size 설정 + // maxBatchSize: 100 + // } + }), + RedisModule.forRoot({ + type: 'single', + url: process.env.REDIS_URL || 'redis://localhost:6379' + }), + QuizModule, + UserModule, + InitDBModule + ], controllers: [AppController], providers: [AppService] }) diff --git a/BE/src/common/constants/exception-message.ts b/BE/src/common/constants/exception-message.ts new file mode 100644 index 0000000..ecec959 --- /dev/null +++ b/BE/src/common/constants/exception-message.ts @@ -0,0 +1,8 @@ +export const ExceptionMessage = { + ROOM_NOT_FOUND: '존재하지 않는 게임 방입니다.', + ROOM_FULL: '게임 방 최대 인원이 모두 찼습니다.', + NOT_A_PLAYER: '해당 게임 방의 플레이어가 아닙니다.', + ONLY_HOST: '방장이 아닙니다.', + GAME_NOT_STARTED: '게임이 시작되지 않았습니다.', + EXCEEDS_QUIZ_SET_LIMIT: '선택된 퀴즈 수가 퀴즈셋에 있는 퀴즈 수를 초과했습니다.' +}; diff --git a/BE/src/common/constants/redis-key.constant.ts b/BE/src/common/constants/redis-key.constant.ts new file mode 100644 index 0000000..9866610 --- /dev/null +++ b/BE/src/common/constants/redis-key.constant.ts @@ -0,0 +1,13 @@ +export const REDIS_KEY = { + ROOM: (gameId: string) => `Room:${gameId}`, + ROOM_PLAYERS: (gameId: string) => `Room:${gameId}:Players`, + ROOM_QUIZ: (gameId: string, quizId: string) => `Room:${gameId}:Quiz:${quizId}`, + ROOM_QUIZ_CHOICES: (gameId: string, quizId: string) => `Room:${gameId}:Quiz:${quizId}:Choices`, + ROOM_LEADERBOARD: (gameId: string) => `Room:${gameId}:Leaderboard`, + ROOM_CURRENT_QUIZ: (gameId: string) => `Room:${gameId}:CurrentQuiz`, + ROOM_TIMER: (gameId: string) => `Room:${gameId}:Timer`, + ROOM_QUIZ_SET: (gameId: string) => `Room:${gameId}:QuizSet`, + PLAYER: (playerId: string) => `Player:${playerId}`, + + ACTIVE_ROOMS: 'ActiveRooms' // 활성화된 방 목록을 저장하는 Set (핀번호 중복 체크하기 위함) +}; diff --git a/BE/src/common/constants/socket-events.ts b/BE/src/common/constants/socket-events.ts index 9d64e70..d9dd1f7 100644 --- a/BE/src/common/constants/socket-events.ts +++ b/BE/src/common/constants/socket-events.ts @@ -6,7 +6,7 @@ const SocketEvents = { UPDATE_ROOM_QUIZSET: 'updateRoomQuizset', JOIN_ROOM: 'joinRoom', START_GAME: 'startGame', - STOP_GAME: 'stopGame', + END_GAME: 'endGame', END_QUIZ_TIME: 'endQuizTime', START_QUIZ_TIME: 'startQuizTime', UPDATE_SCORE: 'updateScore', diff --git a/BE/src/common/entity/base.entity.ts b/BE/src/common/entity/base.entity.ts new file mode 100644 index 0000000..54a0a05 --- /dev/null +++ b/BE/src/common/entity/base.entity.ts @@ -0,0 +1,20 @@ +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; + +export abstract class BaseModel { + @PrimaryGeneratedColumn() + id: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt: Date; +} diff --git a/BE/src/common/exceptions/game.ws.exception.ts b/BE/src/common/exceptions/game.ws.exception.ts new file mode 100644 index 0000000..5b4a9dd --- /dev/null +++ b/BE/src/common/exceptions/game.ws.exception.ts @@ -0,0 +1,9 @@ +export class GameWsException extends Error { + constructor( + // 참고: public readonly -> 자동으로 해당 매개변수를 클래스의 속성으로 선언하고 초기화 + public readonly eventName: string, + public readonly message: string, + ) { + super(message); + } +} \ No newline at end of file diff --git a/BE/src/common/filters/ws-exception.filter.ts b/BE/src/common/filters/ws-exception.filter.ts index 9857395..f8cc071 100644 --- a/BE/src/common/filters/ws-exception.filter.ts +++ b/BE/src/common/filters/ws-exception.filter.ts @@ -1,5 +1,6 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common'; -import { BaseWsExceptionFilter } from '@nestjs/websockets'; +import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; +import { GameWsException } from '../exceptions/game.ws.exception'; @Catch() export class WsExceptionFilter extends BaseWsExceptionFilter { @@ -9,13 +10,12 @@ export class WsExceptionFilter extends BaseWsExceptionFilter { const client = host.switchToWs().getClient(); // ValidationPipe에서 발생한 에러 처리 - if (exception.response) { - this.logger.error(`Validation Error: ${JSON.stringify(exception.response)}`); + if (exception instanceof GameWsException) { + this.logger.error(`Validation Error: ${JSON.stringify(exception.message)}`); - // TODO: API 명세서 확인 client.emit('exception', { - status: 'error', - message: exception.response.message || 'Validation failed' + eventName: exception.eventName, + message: exception.message || 'Validation failed' }); return; } @@ -23,9 +23,8 @@ export class WsExceptionFilter extends BaseWsExceptionFilter { this.logger.error(`WebSocket Error: ${exception.message}`, exception.stack); // 일반적인 WS 예외 처리 - // TODO: API 명세서 확인 client.emit('exception', { - status: 'error', + eventName: exception.eventName, message: exception.message || 'Internal server error' }); } diff --git a/BE/src/common/utils/utils.spec.ts b/BE/src/common/utils/utils.spec.ts index ba4c3fd..843b54b 100644 --- a/BE/src/common/utils/utils.spec.ts +++ b/BE/src/common/utils/utils.spec.ts @@ -3,7 +3,7 @@ import { generateUniquePin } from './utils'; describe('Utils Test', () => { describe('generateUniquePin test', () => { it('generateUniquePin 함수는 6자리 PIN 생성 성공', async () => { - const pin = generateUniquePin(new Map()); + const pin = generateUniquePin(["123445", "412409"]); expect(pin).toMatch(/^\d{6}$/); expect(parseInt(pin)).toBeGreaterThanOrEqual(100000); @@ -11,7 +11,7 @@ describe('Utils Test', () => { }); it('유일한 PIN 생성 성공', async () => { - const rooms = new Map(); + const rooms = ["123445", "412409"]; const pin1 = generateUniquePin(rooms); const pin2 = generateUniquePin(rooms); expect(pin1).not.toBe(pin2); diff --git a/BE/src/common/utils/utils.ts b/BE/src/common/utils/utils.ts index 08ef9bd..fd6187b 100644 --- a/BE/src/common/utils/utils.ts +++ b/BE/src/common/utils/utils.ts @@ -1,4 +1,4 @@ -export function generateUniquePin(rooms) { +export function generateUniquePin(currentRoomPins) { let pin: string; let isUnique = false; let attempts = 0; @@ -9,7 +9,7 @@ export function generateUniquePin(rooms) { pin = Math.floor(100000 + Math.random() * 900000).toString(); // 중복 체크 - const existingRoom = rooms.has(pin); + const existingRoom = currentRoomPins.includes(pin); if (!existingRoom) { isUnique = true; diff --git a/BE/src/game/dto/create-game.dto.ts b/BE/src/game/dto/create-game.dto.ts index c7216ac..5b390c4 100644 --- a/BE/src/game/dto/create-game.dto.ts +++ b/BE/src/game/dto/create-game.dto.ts @@ -1,9 +1,8 @@ import { IsIn, IsInt, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; import { Transform, Type } from 'class-transformer'; -import { GameConfig } from '../game.gateway'; import { WsException } from '@nestjs/websockets'; -export class CreateGameDto implements GameConfig { +export class CreateGameDto { @IsString() @MinLength(1, { message: '제목은 최소 1자 이상이어야 합니다' }) @MaxLength(20, { message: '제목은 최대 20자까지 가능합니다' }) diff --git a/BE/src/game/dto/start-game.dto.ts b/BE/src/game/dto/start-game.dto.ts new file mode 100644 index 0000000..19f7a9a --- /dev/null +++ b/BE/src/game/dto/start-game.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Length } from 'class-validator'; + +export class StartGameDto { + @IsString() + @Length(6, 6, { message: 'PIN번호는 6자리이어야 합니다.' }) + gameId: string; +} diff --git a/BE/src/game/dto/update-position.dto.ts b/BE/src/game/dto/update-position.dto.ts index 2a4ebc9..0ff1d43 100644 --- a/BE/src/game/dto/update-position.dto.ts +++ b/BE/src/game/dto/update-position.dto.ts @@ -1,4 +1,13 @@ -import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsString, Length } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNumber, + IsString, + Length, + Max, + Min +} from 'class-validator'; export class UpdatePositionDto { @IsString() @@ -9,5 +18,7 @@ export class UpdatePositionDto { @ArrayMinSize(2) @ArrayMaxSize(2) @IsNumber({}, { each: true }) + @Min(0, { each: true }) + @Max(1, { each: true }) newPosition: [number, number]; } diff --git a/BE/src/game/dto/update-room-option.dto.ts b/BE/src/game/dto/update-room-option.dto.ts new file mode 100644 index 0000000..0a3f8a9 --- /dev/null +++ b/BE/src/game/dto/update-room-option.dto.ts @@ -0,0 +1,44 @@ +import { IsIn, IsInt, IsString, Length, Max, MaxLength, Min, MinLength } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { WsException } from '@nestjs/websockets'; + +export class UpdateRoomOptionDto { + @IsString() + @Length(6, 6, { message: 'PIN번호는 6자리이어야 합니다.' }) + gameId: string; + + @IsString() + @IsIn(['RANKING', 'SURVIVAL']) + gameMode: string; + + @IsString() + @MinLength(1, { message: '제목은 최소 1자 이상이어야 합니다' }) + @MaxLength(20, { message: '제목은 최대 20자까지 가능합니다' }) + title: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + maxPlayerCount: number; + + @Transform(({ value }) => { + if (value === undefined) { + return true; + } // 기본값 설정 + if (typeof value === 'boolean') { + return value; + } + if (value === 'true' || value === '1' || value === 1) { + return true; + } + if (value === 'false' || value === '0' || value === 0) { + return false; + } + throw new WsException({ + status: 'error', + message: '잘못된 boolean 값입니다' + }); + }) + isPublicGame: boolean; +} diff --git a/BE/src/game/dto/update-room-quizset.dto.ts b/BE/src/game/dto/update-room-quizset.dto.ts new file mode 100644 index 0000000..ed6f363 --- /dev/null +++ b/BE/src/game/dto/update-room-quizset.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, IsString, Length } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateRoomQuizsetDto { + @IsString() + @Length(6, 6, { message: 'PIN번호는 6자리이어야 합니다.' }) + gameId: string; + + @Type(() => Number) + @IsInt() + quizSetId: number; + + @Type(() => Number) + @IsInt() + quizCount: number; +} diff --git a/BE/src/game/game.gateway.spec.ts b/BE/src/game/game.gateway.spec.ts index 09c41bc..b89ec0e 100644 --- a/BE/src/game/game.gateway.spec.ts +++ b/BE/src/game/game.gateway.spec.ts @@ -1,66 +1,4 @@ -import { Test } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { IoAdapter } from '@nestjs/platform-socket.io'; -import { io, Socket } from 'socket.io-client'; -import { GameGateway } from './game.gateway'; -import { GameService } from './game.service'; - -describe('GameGateway (e2e)', () => { - let app: INestApplication; - let client1: Socket; - let client2: Socket; - let client3: Socket; - - const TEST_PORT = 3001; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [GameGateway, GameService] - }).compile(); - - app = moduleRef.createNestApplication(); - app.useWebSocketAdapter(new IoAdapter(app)); - await app.listen(TEST_PORT); - }); - - beforeEach((done) => { - let connectedClients = 0; - const onConnect = () => { - connectedClients++; - if (connectedClients === 3) { - done(); - } - }; - - // 1. http(연결할때만) -> 2. ws - client1 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); - client2 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); - client3 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); - - client1.on('connect', onConnect); - client2.on('connect', onConnect); - client3.on('connect', onConnect); - }); - - afterEach(() => { - client1.close(); - client2.close(); - client3.close(); - }); - - afterAll(async () => { - await app.close(); - }); - +describe('GameGateway 단위테스트', () => { describe('test', () => { it('test', async () => { const a = 1; diff --git a/BE/src/game/game.gateway.ts b/BE/src/game/game.gateway.ts index cbb7b9a..fa57183 100644 --- a/BE/src/game/game.gateway.ts +++ b/BE/src/game/game.gateway.ts @@ -3,56 +3,25 @@ import { MessageBody, SubscribeMessage, WebSocketGateway, - WebSocketServer, - WsException + WebSocketServer } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { Logger, UseFilters, UsePipes, ValidationPipe } from '@nestjs/common'; +import { Logger, UseFilters, UsePipes } from '@nestjs/common'; import { WsExceptionFilter } from '../common/filters/ws-exception.filter'; -import socketEvents from '../common/constants/socket-events'; +import SocketEvents from '../common/constants/socket-events'; import { CreateGameDto } from './dto/create-game.dto'; import { JoinRoomDto } from './dto/join-room.dto'; import { ChatMessageDto } from './dto/chat-message.dto'; -import { GameService } from './game.service'; -import { generateUniquePin } from '../common/utils/utils'; +import { GameService } from './service/game.service'; import { UpdatePositionDto } from './dto/update-position.dto'; - -export type GameConfig = { - title: string; - gameMode: string; - maxPlayerCount: number; - isPublicGame: boolean; -}; - -type GameRoom = { - id: string; - host: string; - players: Map; - config: GameConfig; - createdAt: Date; - status: 'waiting' | 'playing' | 'finished'; -}; - -type player = { - nickname: string; - score: number; - isHost: boolean; - joinedAt: Date; - position: [number, number]; -}; +import { GameValidationPipe } from './validations/game-validation.pipe'; +import { StartGameDto } from './dto/start-game.dto'; +import { UpdateRoomOptionDto } from './dto/update-room-option.dto'; +import { UpdateRoomQuizsetDto } from './dto/update-room-quizset.dto'; +import { GameChatService } from './service/game.chat.service'; +import { GameRoomService } from './service/game.room.service'; @UseFilters(new WsExceptionFilter()) -@UsePipes( - new ValidationPipe({ - transform: true, - exceptionFactory: (errors) => { - return new WsException({ - status: 'error', - message: Object.values(errors[0].constraints)[0] - }); - } - }) -) @WebSocketGateway({ cors: { origin: '*' @@ -63,136 +32,88 @@ export class GameGateway { @WebSocketServer() server: Server; private logger = new Logger('GameGateway'); - private rooms: Map = new Map(); - constructor(private readonly gameService: GameService) {} + constructor( + private readonly gameService: GameService, + private readonly gameChatService: GameChatService, + private readonly gameRoomService: GameRoomService + ) {} - @SubscribeMessage(socketEvents.CREATE_ROOM) - handleCreateRoom( + @SubscribeMessage(SocketEvents.CREATE_ROOM) + @UsePipes(new GameValidationPipe(SocketEvents.CREATE_ROOM)) + async handleCreateRoom( @MessageBody() gameConfig: CreateGameDto, @ConnectedSocket() client: Socket - ): void { - const roomId = generateUniquePin(this.rooms); - const newGame: GameRoom = { - id: roomId, - host: client.id, - players: new Map(), - config: gameConfig, - createdAt: new Date(), - status: 'waiting' - }; - this.rooms.set(roomId, newGame); - - client.emit(socketEvents.CREATE_ROOM, { - gameId: roomId - }); - this.logger.verbose(`게임 방 생성 완료: ${roomId}`); + ): Promise { + const roomId = await this.gameRoomService.createRoom(gameConfig, client.id); + client.emit(SocketEvents.CREATE_ROOM, { gameId: roomId }); } - @SubscribeMessage(socketEvents.JOIN_ROOM) - handleJoinRoom(@MessageBody() dto: JoinRoomDto, @ConnectedSocket() client: Socket): void { - const room = this.rooms.get(dto.gameId); - if (!room) { - client.emit('error', '[ERROR] 존재하지 않는 게임 방입니다.'); - return; - } - if (room.players.size >= room.config.maxPlayerCount) { - client.emit('error', '[ERROR] 게임 방 최대 인원이 모두 찼습니다.'); - return; - } - + @SubscribeMessage(SocketEvents.JOIN_ROOM) + @UsePipes(new GameValidationPipe(SocketEvents.JOIN_ROOM)) + async handleJoinRoom( + @MessageBody() dto: JoinRoomDto, + @ConnectedSocket() client: Socket + ): Promise { client.join(dto.gameId); - const newPlayer: player = { - nickname: dto.playerName, - score: 0, - isHost: room.host === client.id, - joinedAt: new Date(), - position: [Math.random(), Math.random()] - }; - - client.emit(socketEvents.JOIN_ROOM, { - players: Array.from(room.players.entries()).map(([playerId, player]) => ({ - playerId: playerId, - playerName: player.nickname, - playerPosition: player.position - })) - }); - this.server.to(dto.gameId).emit(socketEvents.JOIN_ROOM, { - players: [ - { playerId: client.id, playerName: newPlayer.nickname, playerPosition: newPlayer.position } - ] - }); - - room.players.set(client.id, newPlayer); - this.logger.verbose(`게임 방 입장 완료: ${dto.gameId} - ${client.id} (${dto.playerName})`); + const players = await this.gameRoomService.joinRoom(dto, client.id); + client.emit(SocketEvents.JOIN_ROOM, { players }); } - @SubscribeMessage(socketEvents.UPDATE_POSITION) - handleUpdatePosition( + @SubscribeMessage(SocketEvents.UPDATE_POSITION) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_POSITION)) + async handleUpdatePosition( @MessageBody() updatePosition: UpdatePositionDto, @ConnectedSocket() client: Socket - ): void { - const { gameId, newPosition } = updatePosition; - const room = this.rooms.get(gameId); - - if (!room) { - client.emit('error', '[ERROR] 존재하지 않는 게임 방입니다.'); - return; - } - - const player = room.players.get(client.id); - if (!player) { - client.emit('error', '[ERROR] 해당 게임 방의 플레이어가 아닙니다.'); - return; - } - - player.position = newPosition; - this.server.to(gameId).emit(socketEvents.UPDATE_POSITION, { - playerId: client.id, - playerPosition: newPosition - }); - this.logger.verbose( - `플레이어 위치 업데이트: ${gameId} - ${client.id} (${player.nickname}) = ${newPosition}` - ); + ): Promise { + await this.gameService.updatePosition(updatePosition, client.id); } - @SubscribeMessage(socketEvents.CHAT_MESSAGE) - handleChatMessage( + @SubscribeMessage(SocketEvents.CHAT_MESSAGE) + @UsePipes(new GameValidationPipe(SocketEvents.CHAT_MESSAGE)) + async handleChatMessage( @MessageBody() chatMessage: ChatMessageDto, @ConnectedSocket() client: Socket - ): void { - const { gameId, message } = chatMessage; - const room = this.rooms.get(gameId); - - // TODO: 예외 어노테이션으로 변경하는 법 있는지 확인 - if (!room) { - client.emit('error', '[ERROR] 존재하지 않는 게임 방입니다.'); - return; - } - - const player = room.players.get(client.id); - if (!player) { - client.emit('error', '[ERROR] 해당 게임 방의 플레이어가 아닙니다.'); - return; - } + ): Promise { + await this.gameChatService.chatMessage(chatMessage, client.id); + } - const messageToSend = { - playerId: client.id, - playerName: player.nickname, - message: message, - timestamp: new Date() - }; + @SubscribeMessage(SocketEvents.UPDATE_ROOM_OPTION) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_ROOM_OPTION)) + async handleUpdateRoomOption( + @MessageBody() updateRoomOptionDto: UpdateRoomOptionDto, + @ConnectedSocket() client: Socket + ) { + await this.gameRoomService.updateRoomOption(updateRoomOptionDto, client.id); + } - this.server.to(gameId).emit(socketEvents.CHAT_MESSAGE, messageToSend); - this.logger.verbose( - `채팅 전송: ${gameId} - ${client.id} (${player.nickname}) = ${messageToSend}` - ); + @SubscribeMessage(SocketEvents.UPDATE_ROOM_QUIZSET) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_ROOM_QUIZSET)) + async handleUpdateRoomQuizset( + @MessageBody() updateRoomQuizsetDto: UpdateRoomQuizsetDto, + @ConnectedSocket() client: Socket + ) { + await this.gameRoomService.updateRoomQuizset(updateRoomQuizsetDto, client.id); } - // TODO: 일정 시간 동안 게임 방이 사용되지 않으면 방 정리 (@Cron으로 구현) + @SubscribeMessage(SocketEvents.START_GAME) + @UsePipes(new GameValidationPipe(SocketEvents.START_GAME)) + async handleStartGame( + @MessageBody() startGameDto: StartGameDto, + @ConnectedSocket() client: Socket + ) { + await this.gameService.startGame(startGameDto, client.id); + } afterInit() { this.logger.verbose('WebSocket 서버 초기화 완료했어요!'); + + this.gameService.subscribeRedisEvent(this.server).then(() => { + this.logger.verbose('Redis 이벤트 등록 완료했어요!'); + }); + this.gameChatService.subscribeChatEvent(this.server).then(() => { + this.logger.verbose('Redis Chat 이벤트 등록 완료했어요!'); + }); } handleConnection(client: Socket) { @@ -201,13 +122,7 @@ export class GameGateway { handleDisconnect(client: Socket) { this.logger.verbose(`클라이언트가 연결 해제되었어요!: ${client.id}`); - this.rooms.forEach((room, roomId) => { - room.players.delete(client.id); - if (room.players.size === 0) { - this.rooms.delete(roomId); // TODO: delete는 성능이 좋지 않음. 우선 임시로 사용하고 향후 Redis로 개선 - } - }); - // TODO: 세션만 삭제하는 게 아니라 소켓도 삭제하기 + this.gameService.disconnect(client.id); } } diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 084090d..07c46ab 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -1,8 +1,14 @@ import { Module } from '@nestjs/common'; import { GameGateway } from './game.gateway'; -import { GameService } from './game.service'; +import { GameService } from './service/game.service'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { GameValidator } from './validations/game.validator'; +import { HttpModule } from '@nestjs/axios'; +import { GameChatService } from './service/game.chat.service'; +import { GameRoomService } from './service/game.room.service'; @Module({ - providers: [GameGateway, GameService] + imports: [RedisModule, HttpModule], + providers: [GameGateway, GameService, GameChatService, GameRoomService, GameValidator] }) export class GameModule {} diff --git a/BE/src/game/game.service.ts b/BE/src/game/game.service.ts deleted file mode 100644 index 18ca270..0000000 --- a/BE/src/game/game.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class GameService {} diff --git a/BE/src/game/service/game.chat.service.ts b/BE/src/game/service/game.chat.service.ts new file mode 100644 index 0000000..4a25904 --- /dev/null +++ b/BE/src/game/service/game.chat.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Redis from 'ioredis'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { GameValidator } from '../validations/game.validator'; +import { ChatMessageDto } from '../dto/chat-message.dto'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import SocketEvents from '../../common/constants/socket-events'; +import { Server } from 'socket.io'; + +@Injectable() +export class GameChatService { + private readonly logger = new Logger(GameChatService.name); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly gameValidator: GameValidator + ) {} + + async chatMessage(chatMessage: ChatMessageDto, clientId: string) { + const { gameId, message } = chatMessage; + const roomKey = REDIS_KEY.ROOM(gameId); + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.CHAT_MESSAGE, room); + + const playerKey = REDIS_KEY.PLAYER(clientId); + const player = await this.redis.hgetall(playerKey); + + this.gameValidator.validatePlayerInRoom(SocketEvents.CHAT_MESSAGE, gameId, player); + + await this.redis.publish( + `chat:${gameId}`, + JSON.stringify({ + playerId: clientId, + playerName: player.playerName, + message, + timestamp: new Date() + }) + ); + this.logger.verbose(`채팅 전송: ${gameId} - ${clientId} (${player.playerName}) = ${message}`); + } + + async subscribeChatEvent(server: Server) { + const chatSubscriber = this.redis.duplicate(); + chatSubscriber.psubscribe('chat:*'); + + chatSubscriber.on('pmessage', async (_pattern, channel, message) => { + console.log(`channel: ${channel}`); // channel: chat:317172 + console.log(`message: ${message}`); // message: {"playerId":"8CT28Iw5FgjgPHNyAAAs","playerName":"Player1","message":"Hello, everyone!","timestamp":"2024-11-14T08:32:38.617Z"} + const gameId = channel.split(':')[1]; + const chatMessage = JSON.parse(message); + server.to(gameId).emit(SocketEvents.CHAT_MESSAGE, chatMessage); + }); + } +} diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts new file mode 100644 index 0000000..f38d3d2 --- /dev/null +++ b/BE/src/game/service/game.room.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { GameValidator } from '../validations/game.validator'; +import { CreateGameDto } from '../dto/create-game.dto'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import { generateUniquePin } from '../../common/utils/utils'; +import { JoinRoomDto } from '../dto/join-room.dto'; +import SocketEvents from '../../common/constants/socket-events'; +import { UpdateRoomOptionDto } from '../dto/update-room-option.dto'; +import { UpdateRoomQuizsetDto } from '../dto/update-room-quizset.dto'; + +@Injectable() +export class GameRoomService { + private readonly logger = new Logger(GameRoomService.name); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly gameValidator: GameValidator + ) {} + + async createRoom(gameConfig: CreateGameDto, clientId: string): Promise { + const currentRoomPins = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + const roomId = generateUniquePin(currentRoomPins); + + await this.redis.hmset(REDIS_KEY.ROOM(roomId), { + host: clientId, + status: 'waiting', + title: gameConfig.title, + gameMode: gameConfig.gameMode, + maxPlayerCount: gameConfig.maxPlayerCount.toString(), + isPublicGame: gameConfig.isPublicGame ? '1' : '0', + isWaiting: '1', + lastActivityAt: new Date().getTime().toString(), + quizSetId: '0', + quizCount: '2' + }); + + await this.redis.sadd(REDIS_KEY.ACTIVE_ROOMS, roomId); + this.logger.verbose(`게임 방 생성 완료: ${roomId}`); + + return roomId; + } + + async joinRoom(dto: JoinRoomDto, clientId: string) { + const roomKey = REDIS_KEY.ROOM(dto.gameId); + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.JOIN_ROOM, room); + + const currentPlayers = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(dto.gameId)); + this.gameValidator.validateRoomCapacity( + SocketEvents.JOIN_ROOM, + currentPlayers.length, + parseInt(room.maxPlayerCount) + ); + + const playerKey = REDIS_KEY.PLAYER(clientId); + const positionX = Math.random(); + const positionY = Math.random(); + + await this.redis.set(`${playerKey}:Changes`, 'Join'); + await this.redis.hmset(playerKey, { + playerName: dto.playerName, + positionX: positionX.toString(), + positionY: positionY.toString(), + disconnected: '0', + gameId: dto.gameId + }); + + await this.redis.zadd(REDIS_KEY.ROOM_LEADERBOARD(dto.gameId), 0, clientId); + await this.redis.sadd(REDIS_KEY.ROOM_PLAYERS(dto.gameId), clientId); + + const players = []; + for (const playerId of currentPlayers) { + const player = await this.redis.hgetall(REDIS_KEY.PLAYER(playerId)); + players.push({ + playerId, + playerName: player.playerName, + playerPosition: [parseFloat(player.positionX), parseFloat(player.positionY)] + }); + } + + this.logger.verbose(`게임 방 입장 완료: ${dto.gameId} - ${clientId} (${dto.playerName})`); + + return players; + } + + async updateRoomOption(updateRoomOptionDto: UpdateRoomOptionDto, clientId: string) { + const { gameId, gameMode, title, maxPlayerCount, isPublicGame } = updateRoomOptionDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.UPDATE_ROOM_OPTION, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.UPDATE_ROOM_OPTION, room, clientId); + + await this.redis.set(`${roomKey}:Changes`, 'Option'); + await this.redis.hmset(roomKey, { + title: title, + gameMode: gameMode, + maxPlayerCount: maxPlayerCount.toString(), + isPublicGame: isPublicGame ? '1' : '0' + }); + this.logger.verbose(`게임방 옵션 변경: ${gameId}`); + } + + async updateRoomQuizset(updateRoomQuizsetDto: UpdateRoomQuizsetDto, clientId: string) { + const { gameId, quizSetId, quizCount } = updateRoomQuizsetDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.UPDATE_ROOM_QUIZSET, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.UPDATE_ROOM_QUIZSET, room, clientId); + + await this.redis.set(`${roomKey}:Changes`, 'Quizset'); + await this.redis.hmset(roomKey, { + quizSetId: quizSetId.toString(), + quizCount: quizCount.toString() + }); + this.logger.verbose(`게임방 퀴즈셋 변경: ${gameId}`); + } +} diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts new file mode 100644 index 0000000..d53c980 --- /dev/null +++ b/BE/src/game/service/game.service.ts @@ -0,0 +1,379 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import { UpdatePositionDto } from '../dto/update-position.dto'; +import { GameValidator } from '../validations/game.validator'; +import SocketEvents from '../../common/constants/socket-events'; +import { StartGameDto } from '../dto/start-game.dto'; +import { Server } from 'socket.io'; +import { HttpService } from '@nestjs/axios'; +import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; + +@Injectable() +export class GameService { + private readonly logger = new Logger(GameService.name); + private scoringMap = new Map(); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly httpService: HttpService, + private readonly gameValidator: GameValidator + ) {} + + async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { + const { gameId, newPosition } = updatePosition; + + const playerKey = REDIS_KEY.PLAYER(clientId); + + const player = await this.redis.hgetall(playerKey); + this.gameValidator.validatePlayerInRoom(SocketEvents.UPDATE_POSITION, gameId, player); + + await this.redis.set(`${playerKey}:Changes`, 'Position'); + await this.redis.hmset(playerKey, { + positionX: newPosition[0].toString(), + positionY: newPosition[1].toString() + }); + + this.logger.verbose( + `플레이어 위치 업데이트: ${gameId} - ${clientId} (${player.playerName}) = ${newPosition}` + ); + } + + async startGame(startGameDto: StartGameDto, clientId: string) { + const { gameId } = startGameDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.START_GAME, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.START_GAME, room, clientId); + + // const getQuizsetURL = `http://localhost:3000/api/quizset/${room.quizSetId}`; + // + // // REFACTOR: get 대신 Promise를 반환하는 axiosRef를 사용했으나 더 나은 방식이 있는지 확인 + // const quizset = await this.httpService.axiosRef({ + // url: getQuizsetURL, + // method: 'GET' + // }); + const quizset = mockQuizData; + this.gameValidator.validateQuizsetCount( + SocketEvents.START_GAME, + parseInt(room.quizCount), + quizset.quizList.length + ); + + // Room Quiz 초기화 + const prevQuizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + for (const prevQuiz of prevQuizList) { + await this.redis.del(REDIS_KEY.ROOM_QUIZ(gameId, prevQuiz)); + await this.redis.del(REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, prevQuiz)); + } + await this.redis.del(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + + // 퀴즈셋 랜덤 선택 + const shuffledQuizList = quizset.quizList.sort(() => 0.5 - Math.random()); + const selectedQuizList = shuffledQuizList.slice(0, parseInt(room.quizCount)); + + // 퀴즈들 id 레디스에 등록 + await this.redis.sadd( + REDIS_KEY.ROOM_QUIZ_SET(gameId), + ...selectedQuizList.map((quiz) => quiz.id) + ); + for (const quiz of selectedQuizList) { + await this.redis.hmset(REDIS_KEY.ROOM_QUIZ(gameId, quiz.id), { + quiz: quiz.quiz, + answer: quiz.choiceList.find((choice) => choice.isAnswer).order, + limitTime: quiz.limitTime.toString(), + choiceCount: quiz.choiceList.length.toString() + }); + await this.redis.hmset( + REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, quiz.id), + quiz.choiceList.reduce( + (acc, choice) => { + acc[choice.order] = choice.content; + return acc; + }, + {} as Record + ) + ); + } + + // 리더보드 초기화 + const leaderboard = await this.redis.zrange(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, -1); + for (const playerId of leaderboard) { + await this.redis.zadd(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, playerId); + } + + // 게임이 시작되었음을 알림 + await this.redis.set(`${roomKey}:Changes`, 'Start'); + await this.redis.hmset(roomKey, { + status: 'playing' + }); + + // 첫 퀴즈 걸어주기 + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), '-1:end'); // 0:start, 0:end, 1:start, 1:end + await this.redis.set(REDIS_KEY.ROOM_TIMER(gameId), 'timer', 'EX', 3); + + this.logger.verbose(`게임 시작: ${gameId}`); + } + + async subscribeRedisEvent(server: Server) { + this.redis.config('SET', 'notify-keyspace-events', 'KEhx'); + + // TODO: 분리 필요 + + const scoringSubscriber = this.redis.duplicate(); + await scoringSubscriber.psubscribe('scoring:*'); + scoringSubscriber.on('pmessage', async (_pattern, channel, message) => { + const gameId = channel.split(':')[1]; + + const completeClientsCount = parseInt(message); + if (!this.scoringMap.has(gameId)) { + this.scoringMap[gameId] = 0; + } + this.scoringMap[gameId] += completeClientsCount; + + const playersCount = await this.redis.scard(REDIS_KEY.ROOM_PLAYERS(gameId)); + if (this.scoringMap[gameId] >= playersCount) { + // 채점 완료! + const currentQuiz = await this.redis.get(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId)); + const splitCurrentQuiz = currentQuiz.split(':'); + + const quizNum = parseInt(splitCurrentQuiz[0]); + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[quizNum])); + + const leaderboard = await this.redis.zrange( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 0, + -1, + 'WITHSCORES' + ); + const players = []; + for (let i = 0; i < leaderboard.length; i += 2) { + players.push({ + playerId: leaderboard[i], + score: parseInt(leaderboard[i + 1]), + isAnswer: + (await this.redis.hget(REDIS_KEY.PLAYER(leaderboard[i]), 'isAnswerCorrect')) === '1' + }); + } + server.to(gameId).emit(SocketEvents.END_QUIZ_TIME, { + answer: quiz.answer, + players: players + }); + + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), `${quizNum}:end`); // 현재 퀴즈 상태를 종료 상태로 변경 + await this.redis.set(REDIS_KEY.ROOM_TIMER(gameId), 'timer', 'EX', '10', 'NX'); // 타이머 설정 + this.logger.verbose(`endQuizTime: ${gameId} - ${quizNum}`); + } + }); + + const timerSubscriber = this.redis.duplicate(); + await timerSubscriber.psubscribe(`__keyspace@0__:${REDIS_KEY.ROOM_TIMER('*')}`); + timerSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 3) { + return; + } + const gameId = splitKey[1]; + + if (message === 'expired') { + const currentQuiz = await this.redis.get(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId)); + const splitCurrentQuiz = currentQuiz.split(':'); + const quizNum = parseInt(splitCurrentQuiz[0]); + if (splitCurrentQuiz[1] === 'start') { + // 채점 + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[quizNum])); + // gameId를 통해 해당 room에 있는 client id들을 받기 + const sockets = await server.in(gameId).fetchSockets(); + const clients = sockets.map((socket) => socket.id); + const correctPlayers = []; + for (const clientId of clients) { + const player = await this.redis.hgetall(REDIS_KEY.PLAYER(clientId)); + // 임시로 4개 선택지의 경우만 선정 (1 2 / 3 4) + let selectAnswer = 0; + if (parseFloat(player.positionY) < 0.5) { + if (parseFloat(player.positionX) < 0.5) { + selectAnswer = 1; + } else { + selectAnswer = 2; + } + } else { + if (parseFloat(player.positionX) < 0.5) { + selectAnswer = 3; + } else { + selectAnswer = 4; + } + } + await this.redis.set(`${REDIS_KEY.PLAYER(clientId)}:Changes`, 'AnswerCorrect'); + if (selectAnswer.toString() === quiz.answer) { + correctPlayers.push(clientId); + await this.redis.hmset(REDIS_KEY.PLAYER(clientId), { isAnswerCorrect: '1' }); + } else { + await this.redis.hmset(REDIS_KEY.PLAYER(clientId), { isAnswerCorrect: '0' }); + } + } + for (const clientId of correctPlayers) { + await this.redis.zincrby( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 1000 / correctPlayers.length, + clientId + ); + } + await this.redis.publish(`scoring:${gameId}`, clients.length.toString()); + this.logger.verbose(`채점: ${gameId} - ${clients.length}`); + } else { + // startQuizTime 하는 부분 + const newQuizNum = quizNum + 1; + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + if (quizList.length <= newQuizNum) { + // 마지막 퀴즈이면, 게임 종료! + // 1등을 새로운 호스트로 설정 + const leaderboard = await this.redis.zrange( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 0, + -1, + 'WITHSCORES' + ); + // const players = []; + // for (let i = 0; i < leaderboard.length; i += 2) { + // players.push({ + // playerId: leaderboard[i], + // score: parseInt(leaderboard[i + 1]) + // }); + // } + server.to(gameId).emit(SocketEvents.END_GAME, { + host: leaderboard[0] // 아마 첫 번째가 1등..? + }); + this.logger.verbose(`endGame: ${leaderboard[0]}`); + return; + } + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[newQuizNum])); + const quizChoices = await this.redis.hgetall( + REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, quizList[newQuizNum]) + ); + server.to(gameId).emit(SocketEvents.START_QUIZ_TIME, { + quiz: quiz.quiz, + choiceList: Object.entries(quizChoices).map(([key, value]) => ({ + order: key, + content: value + })), + startTime: Date.now() + 3000, + endTime: Date.now() + (parseInt(quiz.limitTime) + 3) * 1000 + }); + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), `${newQuizNum}:start`); // 현재 퀴즈 상태를 시작 상태로 변경 + await this.redis.set( + REDIS_KEY.ROOM_TIMER(gameId), + 'timer', + 'EX', + (parseInt(quiz.limitTime) + 3).toString(), + 'NX' + ); // 타이머 설정 + this.logger.verbose(`startQuizTime: ${gameId} - ${newQuizNum}`); + } + } + }); + + const roomSubscriber = this.redis.duplicate(); + await roomSubscriber.psubscribe('__keyspace@0__:Room:*'); + roomSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 2) { + return; + } + const gameId = splitKey[1]; + + if (message === 'hset') { + const changes = await this.redis.get(`${key}:Changes`); + const roomData = await this.redis.hgetall(key); + + if (changes === 'Option') { + server.to(gameId).emit(SocketEvents.UPDATE_ROOM_OPTION, { + title: roomData.title, + gameMode: roomData.gameMode, + maxPlayerCount: roomData.maxPlayerCount, + isPublic: roomData.isPublic + }); + } else if (changes === 'Quizset') { + server.to(gameId).emit(SocketEvents.UPDATE_ROOM_QUIZSET, { + quizSetId: roomData.quizSetId, + quizCount: roomData.quizCount + }); + } else if (changes === 'Start') { + server.to(gameId).emit(SocketEvents.START_GAME, ''); + } + } + }); + + const playerSubscriber = this.redis.duplicate(); + playerSubscriber.psubscribe('__keyspace@0__:Player:*'); + playerSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 2) { + return; + } + const playerId = splitKey[1]; + + if (message === 'hset') { + const changes = await this.redis.get(`${key}:Changes`); + const playerData = await this.redis.hgetall(key); + if (changes === 'Join') { + const newPlayer = { + playerId: playerId, + playerName: playerData.playerName, + playerPosition: [parseFloat(playerData.positionX), parseFloat(playerData.positionY)] + }; + server.to(playerData.gameId).emit(SocketEvents.JOIN_ROOM, { + players: [newPlayer] + }); + } else if (changes === 'Position') { + server.to(playerData.gameId).emit(SocketEvents.UPDATE_POSITION, { + playerId: playerId, + playerPosition: [parseFloat(playerData.positionX), parseFloat(playerData.positionY)] + }); + } else if (changes === 'Disconnect') { + server.to(playerData.gameId).emit(SocketEvents.EXIT_ROOM, { + playerId: playerId + }); + } + } + }); + } + + async disconnect(clientId: string) { + const playerKey = REDIS_KEY.PLAYER(clientId); + const playerData = await this.redis.hgetall(playerKey); + + const roomPlayersKey = REDIS_KEY.ROOM_PLAYERS(playerData.gameId); + await this.redis.srem(roomPlayersKey, clientId); + + const roomLeaderboardKey = REDIS_KEY.ROOM_LEADERBOARD(playerData.gameId); + await this.redis.zrem(roomLeaderboardKey, clientId); + + const roomKey = REDIS_KEY.ROOM(playerData.gameId); + const host = await this.redis.hget(roomKey, 'host'); + const players = await this.redis.smembers(roomPlayersKey); + if (host === clientId && players.length > 0) { + const newHost = await this.redis.srandmember(REDIS_KEY.ROOM_PLAYERS(playerData.gameId)); + await this.redis.hmset(roomKey, { + host: newHost + }); + } + await this.redis.set(`${playerKey}:Changes`, 'Disconnect'); + await this.redis.hmset(playerKey, { + disconnected: '1' + }); + + if (players.length === 0) { + await this.redis.del(roomKey); + await this.redis.del(roomPlayersKey); + await this.redis.del(roomLeaderboardKey); + } + } +} diff --git a/BE/src/game/validations/game-validation.pipe.ts b/BE/src/game/validations/game-validation.pipe.ts new file mode 100644 index 0000000..50cc1f0 --- /dev/null +++ b/BE/src/game/validations/game-validation.pipe.ts @@ -0,0 +1,16 @@ +import { GameWsException } from '../../common/exceptions/game.ws.exception'; +import { ValidationPipe } from '@nestjs/common'; + +export class GameValidationPipe extends ValidationPipe { + constructor(eventName: string) { + super({ + transform: true, + exceptionFactory: (errors) => { + return new GameWsException( + eventName, + Object.values(errors[0].constraints)[0] + ); + } + }); + } +} \ No newline at end of file diff --git a/BE/src/game/validations/game.validator.ts b/BE/src/game/validations/game.validator.ts new file mode 100644 index 0000000..ff1e1ee --- /dev/null +++ b/BE/src/game/validations/game.validator.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { GameWsException } from '../../common/exceptions/game.ws.exception'; +import { ExceptionMessage } from '../../common/constants/exception-message'; + +@Injectable() +export class GameValidator { + validateRoomExists(eventName: string, room: any) { + if (!room?.title) { + throw new GameWsException(eventName, ExceptionMessage.ROOM_NOT_FOUND); + } + } + + validateRoomCapacity(eventName: string, currentPlayerCount: number, maxPlayerCount: number) { + if (currentPlayerCount >= maxPlayerCount) { + throw new GameWsException(eventName, ExceptionMessage.ROOM_FULL); + } + } + + validatePlayerInRoom(eventName: string, gameId: string, player: any) { + if (gameId !== player?.gameId) { + throw new GameWsException(eventName, ExceptionMessage.NOT_A_PLAYER); + } + } + + validatePlayerIsHost(eventName: string, room: any, clientId: string) { + if (room?.host !== clientId) { + throw new GameWsException(eventName, ExceptionMessage.ONLY_HOST); + } + } + + validateQuizsetCount(eventName: string, selectedQuizsetCount: number, quizsetCount: number) { + if (selectedQuizsetCount > quizsetCount) { + throw new GameWsException(eventName, ExceptionMessage.EXCEEDS_QUIZ_SET_LIMIT); + } + } +} diff --git a/BE/src/main.ts b/BE/src/main.ts index 6f931b4..c5f2603 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -1,11 +1,12 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - await app.listen(3000); - Logger.log('Application running on port 3000'); -} -bootstrap(); +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + await app.listen(3000); + Logger.log('Application running on port 3000'); +} + +bootstrap(); diff --git a/BE/src/quiz/dto/Response.dto.ts b/BE/src/quiz/dto/Response.dto.ts new file mode 100644 index 0000000..e19975d --- /dev/null +++ b/BE/src/quiz/dto/Response.dto.ts @@ -0,0 +1,7 @@ +export class Result { + constructor(data: T) { + this.quizSetList = data; + } + + quizSetList: T; +} diff --git a/BE/src/quiz/dto/create-quiz.dto.ts b/BE/src/quiz/dto/create-quiz.dto.ts new file mode 100644 index 0000000..93cd757 --- /dev/null +++ b/BE/src/quiz/dto/create-quiz.dto.ts @@ -0,0 +1,52 @@ +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsNumber, + IsString, + Max, + Min, + ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateChoiceDto { + @IsString() + choiceContent: string; + + @IsNumber() + choiceOrder: number; + + @IsBoolean() + isAnswer: boolean; +} + +export class CreateQuizDto { + @IsString() + quiz: string; + + @IsNumber() + @Min(1) + @Max(3600) + limitTime: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateChoiceDto) + @ArrayMinSize(2) + choiceList: CreateChoiceDto[]; +} + +export class CreateQuizSetDto { + @IsString() + title: string; + + @IsString() + category: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateQuizDto) + @ArrayMinSize(1) + quizList: CreateQuizDto[]; +} diff --git a/BE/src/quiz/dto/update-quiz.dto.ts b/BE/src/quiz/dto/update-quiz.dto.ts new file mode 100644 index 0000000..ff0225e --- /dev/null +++ b/BE/src/quiz/dto/update-quiz.dto.ts @@ -0,0 +1,58 @@ +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateChoiceDto { + @IsString() + @IsOptional() + choiceContent?: string; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(10) + choiceOrder?: number; + + @IsBoolean() + @IsOptional() + isAnswer?: boolean; +} + +export class UpdateQuizDto { + @IsString() + @IsOptional() + quiz?: string; + + @IsNumber() + @IsOptional() + @Min(10) + @Max(3600) + limitTime?: number; + + @ValidateNested({ each: true }) + @Type(() => UpdateChoiceDto) + @IsOptional() + choiceList?: UpdateChoiceDto[]; +} + +export class UpdateQuizSetDto { + @IsString() + @IsOptional() + title?: string; + + @IsString() + @IsOptional() + category?: string; + + @ValidateNested({ each: true }) + @Type(() => UpdateQuizDto) + @IsOptional() + quizList?: UpdateQuizDto[]; +} diff --git a/BE/src/quiz/entities/quiz-choice.entity.ts b/BE/src/quiz/entities/quiz-choice.entity.ts new file mode 100644 index 0000000..162324f --- /dev/null +++ b/BE/src/quiz/entities/quiz-choice.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { QuizModel } from './quiz.entity'; +import { BaseModel } from '../../common/entity/base.entity'; + +@Entity('quiz_choice') +export class QuizChoiceModel extends BaseModel { + @Column({ name: 'quiz_id', type: 'bigint' }) + quizId: number; + + @Column({ name: 'is_answer', type: 'boolean', default: false }) + isAnswer: boolean; + + @Column({ name: 'choice_content', type: 'text' }) + choiceContent: string; + + @Column({ name: 'choice_order', type: 'integer' }) + choiceOrder: number; + + @ManyToOne(() => QuizModel, (quiz) => quiz.choiceList, { + onDelete: 'CASCADE', + lazy: true + }) + @JoinColumn({ name: 'quiz_id' }) + quiz: QuizModel; +} diff --git a/BE/src/quiz/entities/quiz-set.entity.ts b/BE/src/quiz/entities/quiz-set.entity.ts new file mode 100644 index 0000000..250a223 --- /dev/null +++ b/BE/src/quiz/entities/quiz-set.entity.ts @@ -0,0 +1,33 @@ +import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { BaseModel } from '../../common/entity/base.entity'; +import { UserModel } from '../../user/entities/user.entity'; +import { QuizModel } from './quiz.entity'; +import { UserQuizArchiveModel } from '../../user/entities/user-quiz-archive.entity'; + +@Entity('quiz_set') +export class QuizSetModel extends BaseModel { + @Column() + title: string; + + @Column({ name: 'user_id' }) + userId: number; + + @Column() + category: string; + + @ManyToOne(() => UserModel, (user) => user.quizSetList, { + lazy: true + }) + @JoinColumn({ name: 'user_id' }) + user: UserModel; + + @OneToMany(() => QuizModel, (quiz) => quiz.quizSet) + quizList: QuizModel[]; + + @OneToMany(() => UserQuizArchiveModel, (archive) => archive.quizSet) + archiveList: UserQuizArchiveModel[]; + + @CreateDateColumn() + @Index() + createdAt: Date; +} diff --git a/BE/src/quiz/entities/quiz.entity.ts b/BE/src/quiz/entities/quiz.entity.ts new file mode 100644 index 0000000..a51fac7 --- /dev/null +++ b/BE/src/quiz/entities/quiz.entity.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { BaseModel } from '../../common/entity/base.entity'; +import { QuizSetModel } from './quiz-set.entity'; +import { QuizChoiceModel } from './quiz-choice.entity'; + +@Entity('quiz') +export class QuizModel extends BaseModel { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'quiz_set_id' }) + quizSetId: number; + + @Column('text') + quiz: string; + + @Column({ name: 'limit_time' }) + limitTime: number; + + @ManyToOne(() => QuizSetModel, (quizSet) => quizSet.quizList, { + lazy: true + }) + @JoinColumn({ name: 'quiz_set_id' }) + quizSet: QuizSetModel; + + @OneToMany(() => QuizChoiceModel, (choice) => choice.quiz) + choiceList: QuizChoiceModel[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date; +} diff --git a/BE/src/quiz/quiz.controller.ts b/BE/src/quiz/quiz.controller.ts new file mode 100644 index 0000000..bfc6caf --- /dev/null +++ b/BE/src/quiz/quiz.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query +} from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { QuizService } from './quiz.service'; +import { UpdateQuizSetDto } from './dto/update-quiz.dto'; +import { CreateQuizSetDto } from './dto/create-quiz.dto'; + +@Controller('/api/quizset') +export class QuizController { + constructor(private readonly quizService: QuizService) {} + + @Post() + @ApiOperation({ summary: '퀴즈셋 생성' }) + @ApiResponse({ status: 201, description: '퀴즈셋이 성공적으로 생성됨' }) + @ApiResponse({ status: 400, description: '잘못된 입력값' }) + async createQuizSet(@Body() createQuizSetDto: CreateQuizSetDto) { + return this.quizService.createQuizSet(createQuizSetDto); + } + + @Get() + findAll( + @Query('category') category: string, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + @Query('size', new DefaultValuePipe(10), ParseIntPipe) limit: number + ) { + return this.quizService.findAllWithQuizzesAndChoices(category, offset, limit); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.quizService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateQuizSetDto: UpdateQuizSetDto) { + return this.quizService.update(+id, updateQuizSetDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.quizService.remove(+id); + } +} diff --git a/BE/src/quiz/quiz.module.ts b/BE/src/quiz/quiz.module.ts new file mode 100644 index 0000000..4279c83 --- /dev/null +++ b/BE/src/quiz/quiz.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { QuizService } from './quiz.service'; +import { QuizController } from './quiz.controller'; +import { UserModule } from '../user/user.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { QuizSetModel } from './entities/quiz-set.entity'; +import { QuizModel } from './entities/quiz.entity'; +import { QuizChoiceModel } from './entities/quiz-choice.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([QuizSetModel, QuizModel, QuizChoiceModel]), + UserModule // 필요한 다른 모듈 import + ], + controllers: [QuizController], + providers: [QuizService], + exports: [QuizService] +}) +export class QuizModule {} diff --git a/BE/src/quiz/quiz.service.spec.ts b/BE/src/quiz/quiz.service.spec.ts new file mode 100644 index 0000000..be95576 --- /dev/null +++ b/BE/src/quiz/quiz.service.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource, Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { QuizService } from './quiz.service'; +import { QuizModel } from './entities/quiz.entity'; +import { QuizSetModel } from './entities/quiz-set.entity'; +import { QuizChoiceModel } from './entities/quiz-choice.entity'; + +describe('QuizService', () => { + let service: QuizService; + let quizRepository: Repository; + let quizSetRepository: Repository; + let quizChoiceRepository: Repository; + let dataSource: DataSource; + + const mockRepository = { + createQuizSet: jest.fn(), + findAllWithQuizzesAndChoices: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn() + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QuizService, + { + provide: getRepositoryToken(QuizModel), + useValue: mockRepository + }, + { + provide: getRepositoryToken(QuizSetModel), + useValue: mockRepository + }, + { + provide: getRepositoryToken(QuizChoiceModel), + useValue: mockRepository + }, + { + provide: DataSource, // DataSource provider + useValue: { + createQueryRunner: jest.fn(() => ({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + findOne: jest.fn(), + save: jest.fn() + } + })), + transaction: jest.fn((cb) => + cb({ + findOne: jest.fn(), + save: jest.fn() + }) + ) + } + } + ] + }).compile(); + + service = module.get(QuizService); + quizRepository = module.get>(getRepositoryToken(QuizModel)); + quizSetRepository = module.get>(getRepositoryToken(QuizSetModel)); + quizChoiceRepository = module.get>( + getRepositoryToken(QuizChoiceModel) + ); + dataSource = module.get(DataSource); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('repository should be defined', () => { + expect(quizRepository).toBeDefined(); + expect(quizSetRepository).toBeDefined(); + expect(quizChoiceRepository).toBeDefined(); + }); +}); diff --git a/BE/src/quiz/quiz.service.ts b/BE/src/quiz/quiz.service.ts new file mode 100644 index 0000000..4dd39fb --- /dev/null +++ b/BE/src/quiz/quiz.service.ts @@ -0,0 +1,373 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException +} from '@nestjs/common'; +import { CreateQuizSetDto } from './dto/create-quiz.dto'; +import { UpdateQuizSetDto } from './dto/update-quiz.dto'; +import { QuizModel } from './entities/quiz.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, IsNull, QueryFailedError, Repository } from 'typeorm'; +import { QuizSetModel } from './entities/quiz-set.entity'; +import { QuizChoiceModel } from './entities/quiz-choice.entity'; +import { Result } from './dto/Response.dto'; +import { groupBy } from 'lodash'; +import { UserModel } from '../user/entities/user.entity'; + +@Injectable() +export class QuizService { + constructor( + @InjectRepository(QuizModel) + private readonly quizRepository: Repository, + @InjectRepository(QuizSetModel) + private readonly quizSetRepository: Repository, + @InjectRepository(QuizChoiceModel) + private readonly quizChoiceRepository: Repository, + private dataSource: DataSource + ) {} + + async findAllWithQuizzesAndChoices(category: string, offset: number, limit: number) { + // 1. QuizSet 페이징 조회 + const quizSets = await this.quizSetRepository.find({ + where: { + category, + deletedAt: IsNull() // soft delete 고려 + }, + skip: offset, + take: limit, + order: { + createdAt: 'DESC' + } + }); + + if (quizSets.length === 0) { + return new Result([]); + } + + // 2. Quiz 한 번에 조회 + const quizSetIds = quizSets.map((qs) => qs.id); + const quizzes = await this.quizRepository + .createQueryBuilder('quiz') + .where('quiz.quizSetId IN (:...quizSetIds)', { quizSetIds }) + .andWhere('quiz.deletedAt IS NULL') + .getMany(); + + // 3. Choice 한 번에 조회 + const quizIds = quizzes.map((q) => q.id); + const choices = await this.quizChoiceRepository + .createQueryBuilder('choice') + .where('choice.quizId IN (:...quizIds)', { quizIds }) + .andWhere('choice.deletedAt IS NULL') + .getMany(); + + // 4. 메모리에서 관계 매핑 + const choicesByQuizId = groupBy(choices, 'quizId'); + const quizzesByQuizSetId = groupBy(quizzes, 'quizSetId'); + + const dtos = this.quizSetToDto(quizSets, quizzesByQuizSetId, choicesByQuizId); + + return new Result(dtos); + } + + private quizSetToDto(quizSets: QuizSetModel[], quizzesByQuizSetId, choicesByQuizId) { + const dtos = quizSets.map((quizSet) => ({ + id: quizSet.id.toString(), + title: quizSet.title, + category: quizSet.category, + quizList: (quizzesByQuizSetId[quizSet.id] || []).map((quiz) => ({ + id: quiz.id.toString(), + quiz: quiz.quiz, + limitTime: quiz.limitTime, + choiceList: (choicesByQuizId[quiz.id] || []).map((choice) => ({ + content: choice.choiceContent, + order: choice.choiceOrder + })) + })) + })); + return dtos; + } + + /** + * 현재 api 명세에 따라 user 정보는 안주는것으로 구현되어있음. + * 이에따라 test code에서도 user 정보를 test 하지 않음. + * 향후 필요시 구현가능(relation 옵션 활용) + * @param id + */ + async findOne(id: number) { + // 1. QuizSet 조회 + const quizSet = await this.quizSetRepository.findOne({ + where: { id, deletedAt: IsNull() } + }); + + if (!quizSet) { + throw new NotFoundException(`QuizSet with id ${id} not found`); + } + + // 2. Quiz 조회 + const quizzes = await this.quizRepository + .createQueryBuilder('quiz') + .where('quiz.quizSetId = :quizSetId', { quizSetId: id }) + .andWhere('quiz.deletedAt IS NULL') + .getMany(); + + // 3. Choice 조회 + const quizIds = quizzes.map((q) => q.id); + const choices = await this.quizChoiceRepository + .createQueryBuilder('choice') + .where('choice.quizId IN (:...quizIds)', { quizIds }) + .andWhere('choice.deletedAt IS NULL') + .getMany(); + + // 4. 메모리에서 관계 매핑 + const choicesByQuizId = groupBy(choices, 'quizId'); + + // 5. DTO 변환 + const dto = { + id: quizSet.id.toString(), + title: quizSet.title, + category: quizSet.category, + quizList: quizzes.map((quiz) => ({ + id: quiz.id.toString(), + quiz: quiz.quiz, + limitTime: quiz.limitTime, + choiceList: (choicesByQuizId[quiz.id] || []).map((choice) => ({ + content: choice.choiceContent, + order: choice.choiceOrder, + isAnswer: choice.isAnswer + })) + })) + }; + + return dto; + } + + async remove(id: number) { + return this.dataSource.transaction(async (manager) => { + // 1. 퀴즈셋 조회 (삭제되지 않은 것만) + const quizSet = await manager.findOne(QuizSetModel, { + where: { + id, + deletedAt: IsNull() // soft delete 되지 않은 것만 조회 + }, + relations: ['user'] + }); + + if (!quizSet) { + throw new NotFoundException(`ID ${id}인 퀴즈셋을 찾을 수 없습니다.`); + } + + //todo : 토큰으로 로그인한 사용자 정보 가져오기 && 권한 확인 + // if (quizSet.userId !== userId) { + // throw new ForbiddenException('해당 퀴즈셋을 삭제할 권한이 없습니다.'); + // } + + // 2. Soft Delete 실행 + await manager.softRemove(quizSet); + + return { + success: true, + message: '퀴즈셋이 성공적으로 삭제되었습니다.' + }; + }); + } + + /** + * 퀴즈셋, 퀴즈, 선택지를 생성합니다. + * @param createQuizSetDto 생성할 퀴즈셋 데이터 + * @returns 생성된 퀴즈셋 + */ + async createQuizSet(createQuizSetDto: CreateQuizSetDto) { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + this.validateQuizSet(createQuizSetDto); + + // 1. 유저 존재 확인 + // TODO : 토큰으로 로그인한 사용자 정보 가져오기 + // 1. 유저 찾기 또는 생성 + let user = await queryRunner.manager.findOne(UserModel, { + where: { email: 'honux@codesquad.co.kr' } + }); + + // 유저가 없다면 생성 + if (!user) { + user = queryRunner.manager.create(UserModel, { + email: 'honux@codesquad.co.kr', + password: '123456', + nickname: 'honux', + point: 100, + status: 'ACTIVE' + }); + await queryRunner.manager.save(user); + } + + // 2. 퀴즈셋 생성 + const quizSet = queryRunner.manager.create(QuizSetModel, { + title: createQuizSetDto.title, + category: createQuizSetDto.category, + user, + userId: user.id + }); + await queryRunner.manager.save(quizSet); + + // 2. 퀴즈 생성 + for (const quizData of createQuizSetDto.quizList) { + // 2.1 퀴즈 엔티티 생성 및 저장 + const quiz = queryRunner.manager.create(QuizModel, { + quizSet, + quiz: quizData.quiz, + limitTime: quizData.limitTime + }); + await queryRunner.manager.save(quiz); + + // 2.2 선택지 생성 + const choices = quizData.choiceList.map((choiceData) => + queryRunner.manager.create(QuizChoiceModel, { + quiz, + choiceContent: choiceData.choiceContent, + choiceOrder: choiceData.choiceOrder, + isAnswer: choiceData.isAnswer + }) + ); + await queryRunner.manager.save(choices); + } + + // 3. 생성된 퀴즈셋 조회 (관계 데이터 포함) + const savedQuizSet = await queryRunner.manager.findOne(QuizSetModel, { + where: { id: quizSet.id } + }); + + await queryRunner.commitTransaction(); + + const ret = { + data: { + id: savedQuizSet.id + } + }; + + return ret; + } catch (error) { + await queryRunner.rollbackTransaction(); + + // BadRequestException은 그대로 전파 + if (error instanceof BadRequestException) { + throw error; + } + + // DB 관련 에러 처리 + if (error instanceof QueryFailedError) { + throw new BadRequestException(`데이터베이스 오류: ${error.message}`); + } + + // 그 외 에러는 InternalServerError로 변환 + throw new InternalServerErrorException(`퀴즈셋 생성 실패: ${error.message}`); + } finally { + await queryRunner.release(); + } + } + + private validateQuizSet(quizSet: CreateQuizSetDto): void { + for (const quiz of quizSet.quizList) { + // 정답이 하나 이상 존재하는지 확인 + const answerCount = quiz.choiceList.filter((choice) => choice.isAnswer).length; + if (answerCount === 0) { + throw new BadRequestException(`퀴즈 "${quiz.quiz}"에 정답이 없습니다.`); + } + + // 선택지 번호가 중복되지 않는지 확인 + const orders = new Set(quiz.choiceList.map((choice) => choice.choiceOrder)); + if (orders.size !== quiz.choiceList.length) { + throw new BadRequestException(`퀴즈 "${quiz.quiz}"의 선택지 번호가 중복됩니다.`); + } + } + } + + async update(id: number, updateDto: UpdateQuizSetDto) { + // 트랜잭션 시작 + return this.dataSource.transaction(async (manager) => { + // 퀴즈셋 조회 + const quizSet = await manager.findOne(QuizSetModel, { + where: { id }, + relations: { + user: true, + quizList: { + choiceList: true + } + } + }); + + if (!quizSet) { + throw new NotFoundException(`ID ${id}인 퀴즈셋을 찾을 수 없습니다.`); + } + + // 1. 기본 필드 업데이트 (변경감지 사용) + if (updateDto.title) { + quizSet.title = updateDto.title; + } + if (updateDto.category) { + quizSet.category = updateDto.category; + } + + // 2. 퀴즈 업데이트 + if (updateDto.quizList) { + await Promise.all( + updateDto.quizList.map(async (quizDto, index) => { + const quiz = quizSet.quizList[index] || new QuizModel(); + + // 2.1 퀴즈 필드 업데이트 (변경감지 사용) + if (quizDto.quiz) { + quiz.quiz = quizDto.quiz; + } + if (quizDto.limitTime) { + quiz.limitTime = quizDto.limitTime; + } + + // 2.2 선택지 업데이트 + if (quizDto.choiceList) { + quiz.choiceList = await Promise.all( + quizDto.choiceList.map(async (choiceDto, choiceIndex) => { + const choice = quiz.choiceList?.[choiceIndex] || new QuizChoiceModel(); + + // 선택지 필드 업데이트 (변경감지 사용) + if (choiceDto.choiceContent) { + choice.choiceContent = choiceDto.choiceContent; + } + if (choiceDto.choiceOrder) { + choice.choiceOrder = choiceDto.choiceOrder; + } + if (choiceDto.isAnswer !== undefined) { + choice.isAnswer = choiceDto.isAnswer; + } + + await manager.save(choice); + return choice; + }) + ); + } + + if (!quiz.id) { + quiz.quizSet = quizSet; + } + + await manager.save(quiz); + }) + ); + } + + // 3. 변경사항 저장 + await manager.save(quizSet); + + const ret = { + data: { + id: quizSet.id + } + }; + + return ret; + }); + } +} diff --git a/BE/src/user/dto/create-user.dto.ts b/BE/src/user/dto/create-user.dto.ts new file mode 100644 index 0000000..0311be1 --- /dev/null +++ b/BE/src/user/dto/create-user.dto.ts @@ -0,0 +1 @@ +export class CreateUserDto {} diff --git a/BE/src/user/dto/update-user.dto.ts b/BE/src/user/dto/update-user.dto.ts new file mode 100644 index 0000000..dfd37fb --- /dev/null +++ b/BE/src/user/dto/update-user.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/BE/src/user/entities/user-quiz-archive.entity.ts b/BE/src/user/entities/user-quiz-archive.entity.ts new file mode 100644 index 0000000..b914ef7 --- /dev/null +++ b/BE/src/user/entities/user-quiz-archive.entity.ts @@ -0,0 +1,49 @@ +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { BaseModel } from '../../common/entity/base.entity'; +import { QuizSetModel } from '../../quiz/entities/quiz-set.entity'; +import { UserModel } from './user.entity'; + +export enum GameMode { + SURVIVAL = 'SURVIVAL', + RANKING = 'RANKING' +} + +@Entity('user_quiz_archive') +export class UserQuizArchiveModel extends BaseModel { + @Column({ name: 'user_id', type: 'bigint' }) + userId: number; + + @Column({ name: 'quiz_set_id', type: 'bigint' }) + quizSetId: number; + + @Column({ + type: 'enum', // varchar가 아닌 enum 사용 + enum: GameMode, // ['SURVIVAL', 'RANKING'] + default: GameMode.RANKING // 기본값 설정 + }) + gameMode: GameMode; + + @Column({ name: 'player_count', type: 'integer' }) + playerCount: number; + + @Column({ type: 'integer' }) + rank: number; + + @Column({ type: 'integer' }) + score: number; + + @Column({ name: 'played_at', type: 'timestamp' }) + playedAt: Date; + + @ManyToOne(() => UserModel, (user) => user.quizArchiveList, { + lazy: true + }) + @JoinColumn({ name: 'user_id' }) + user: UserModel; + + @ManyToOne(() => QuizSetModel, { + lazy: true + }) + @JoinColumn({ name: 'quiz_set_id' }) + quizSet: QuizSetModel; +} diff --git a/BE/src/user/entities/user.entity.ts b/BE/src/user/entities/user.entity.ts new file mode 100644 index 0000000..25388d3 --- /dev/null +++ b/BE/src/user/entities/user.entity.ts @@ -0,0 +1,36 @@ +import { Column, Entity, OneToMany } from 'typeorm'; +import { BaseModel } from '../../common/entity/base.entity'; +import { Exclude } from 'class-transformer'; +import { QuizSetModel } from '../../quiz/entities/quiz-set.entity'; +import { UserQuizArchiveModel } from './user-quiz-archive.entity'; + +@Entity('user') +export class UserModel extends BaseModel { + @Column({ unique: true }) + email: string; + + @Column() + @Exclude() //직렬화 대상에서 제외 + password: string; + + @Column() + nickname: string; + + @Column({ name: 'profile_image', nullable: true }) + profileImage: string; + + @Column({ default: 0 }) + point: number; + + @Column() + status: string; + + @Column({ name: 'last_login_at', nullable: true }) + lastLoginAt: Date; + + @OneToMany(() => QuizSetModel, (quizSet) => quizSet.user) + quizSetList: QuizSetModel[]; + + @OneToMany(() => UserQuizArchiveModel, (archive) => archive.user) + quizArchiveList: UserQuizArchiveModel[]; +} diff --git a/BE/src/user/user.controller.spec.ts b/BE/src/user/user.controller.spec.ts new file mode 100644 index 0000000..1f38440 --- /dev/null +++ b/BE/src/user/user.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +describe('UserController', () => { + let controller: UserController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [UserService], + }).compile(); + + controller = module.get(UserController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/BE/src/user/user.controller.ts b/BE/src/user/user.controller.ts new file mode 100644 index 0000000..995519c --- /dev/null +++ b/BE/src/user/user.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { UserService } from './user.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Post() + create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto); + } + + @Get() + findAll() { + return this.userService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.userService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.userService.update(+id, updateUserDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.userService.remove(+id); + } +} diff --git a/BE/src/user/user.module.ts b/BE/src/user/user.module.ts new file mode 100644 index 0000000..86ed1e0 --- /dev/null +++ b/BE/src/user/user.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; +import { UserController } from './user.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserModel } from './entities/user.entity'; +import { UserQuizArchiveModel } from './entities/user-quiz-archive.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserModel, UserQuizArchiveModel])], + controllers: [UserController], + providers: [UserService], + exports: [UserService] +}) +export class UserModule {} diff --git a/BE/src/user/user.service.spec.ts b/BE/src/user/user.service.spec.ts new file mode 100644 index 0000000..873de8a --- /dev/null +++ b/BE/src/user/user.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UserService], + }).compile(); + + service = module.get(UserService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/BE/src/user/user.service.ts b/BE/src/user/user.service.ts new file mode 100644 index 0000000..95707d7 --- /dev/null +++ b/BE/src/user/user.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UserService { + create(createUserDto: CreateUserDto) { + return 'This action adds a new user'; + } + + findAll() { + return `This action returns all user`; + } + + findOne(id: number) { + return `This action returns a #${id} user`; + } + + update(id: number, updateUserDto: UpdateUserDto) { + return `This action updates a #${id} user`; + } + + remove(id: number) { + return `This action removes a #${id} user`; + } +} diff --git a/BE/test/app.e2e-spec.ts b/BE/test/app.e2e-spec.ts deleted file mode 100644 index 8c6434f..0000000 --- a/BE/test/app.e2e-spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index a790e21..bf68e1a 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -3,20 +3,73 @@ import { INestApplication } from '@nestjs/common'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { io, Socket } from 'socket.io-client'; import { GameGateway } from '../src/game/game.gateway'; -import { GameService } from '../src/game/game.service'; +import { GameService } from '../src/game/service/game.service'; import socketEvents from '../src/common/constants/socket-events'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { Redis } from 'ioredis'; +import { GameValidator } from '../src/game/validations/game.validator'; +import { GameChatService } from '../src/game/service/game.chat.service'; +import { GameRoomService } from '../src/game/service/game.room.service'; +import { HttpService } from '@nestjs/axios'; +import { mockQuizData } from './mocks/quiz-data.mock'; +import RedisMock from 'ioredis-mock'; +import { REDIS_KEY } from '../src/common/constants/redis-key.constant'; + +const mockHttpService = { + axiosRef: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: mockQuizData, + status: 200 + }); + }) +}; describe('GameGateway (e2e)', () => { let app: INestApplication; let client1: Socket; let client2: Socket; let client3: Socket; + let redisMock: Redis; const TEST_PORT = 3001; beforeAll(async () => { + /* ioredis-mock을 사용하여 테스트용 인메모리 Redis 생성 */ + redisMock = new RedisMock(); + jest.spyOn(redisMock, 'config').mockImplementation(() => Promise.resolve('OK')); + + // hset 메소드 오버라이드 + const originalHset = redisMock.hmset.bind(redisMock); + redisMock.hmset = async function (key: string, ...args: any[]) { + const result = await originalHset(key, ...args); + + await this.publish(`__keyspace@0__:${key}`, 'hset'); + + return result; + }; + const moduleRef = await Test.createTestingModule({ - providers: [GameGateway, GameService] + imports: [ + RedisModule.forRoot({ + type: 'single', + url: 'redis://localhost:6379' + }) + ], + providers: [ + GameGateway, + GameService, + GameChatService, + GameRoomService, + GameValidator, + { + provide: 'default_IORedisModuleConnectionToken', + useValue: redisMock + }, + { + provide: HttpService, + useValue: mockHttpService + } + ] }).compile(); app = moduleRef.createNestApplication(); @@ -24,42 +77,54 @@ describe('GameGateway (e2e)', () => { await app.listen(TEST_PORT); }); - beforeEach((done) => { - let connectedClients = 0; - const onConnect = () => { - connectedClients++; - if (connectedClients === 3) { - done(); - } - }; + beforeEach(async () => { + await redisMock.flushall(); - // 1. http(연결할때만) -> 2. ws - client1 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); - client2 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); - client3 = io(`http://localhost:${TEST_PORT}/game`, { - transports: ['websocket'], - forceNew: true - }); + return new Promise((resolve) => { + let connectedClients = 0; + const onConnect = () => { + connectedClients++; + if (connectedClients === 3) { + resolve(); + } + }; + + client1 = io(`http://localhost:${TEST_PORT}/game`, { + transports: ['websocket'], + forceNew: true + }); + client2 = io(`http://localhost:${TEST_PORT}/game`, { + transports: ['websocket'], + forceNew: true + }); + client3 = io(`http://localhost:${TEST_PORT}/game`, { + transports: ['websocket'], + forceNew: true + }); - client1.on('connect', onConnect); - client2.on('connect', onConnect); - client3.on('connect', onConnect); + client1.on('connect', onConnect); + client2.on('connect', onConnect); + client3.on('connect', onConnect); + }); }); - afterEach(() => { - client1.close(); - client2.close(); - client3.close(); + afterEach(async () => { + if (client1 && client1.connected) { + client1.disconnect(); + } + if (client2 && client2.connected) { + client2.disconnect(); + } + if (client3 && client3.connected) { + client3.disconnect(); + } + await redisMock.flushall(); }); afterAll(async () => { - await app.close(); + if (app) { + await app.close(); + } }); describe('createRoom 이벤트 테스트', () => { @@ -76,15 +141,21 @@ describe('GameGateway (e2e)', () => { client1.emit(socketEvents.CREATE_ROOM, gameConfig); }); - // expect(response.status).toBe('success'); expect(response.gameId).toBeDefined(); expect(typeof response.gameId).toBe('string'); + + // 실제 Redis 저장 확인 + const roomData = await redisMock.hgetall(`Room:${response.gameId}`); + expect(roomData).toBeDefined(); + expect(roomData.title).toBe(gameConfig.title); + expect(roomData.gameMode).toBe(gameConfig.gameMode); + expect(roomData.maxPlayerCount).toBe(gameConfig.maxPlayerCount.toString()); }); const invalidConfigs = [ { case: '빈 title', - config: { title: '', gameMode: '', maxPlayerCount: 2, isPublicGame: true } + config: { title: '', gameMode: 'RANKING', maxPlayerCount: 2, isPublicGame: true } }, { case: '빈 gameMode', @@ -96,38 +167,28 @@ describe('GameGateway (e2e)', () => { }, { case: '최소 인원 미달', - config: { title: 'hello', gameMode: 'ranking', maxPlayerCount: 0, isPublicGame: true } - }, - { - case: '최대 인원 초과', - config: { title: 'hello', gameMode: 'ranking', maxPlayerCount: 201, isPublicGame: true } - }, - { - case: '잘못된 boolean 타입', - config: { title: 'hello', gameMode: 'ranking', maxPlayerCount: 2, isPublicGame: '안녕' } + config: { title: 'hello', gameMode: 'RANKING', maxPlayerCount: 0, isPublicGame: true } } ]; invalidConfigs.forEach(({ case: testCase, config }) => { - it(testCase, (done) => { + it(`${testCase}인 경우 에러 발생`, (done) => { client1.once('exception', (error) => { expect(error).toBeDefined(); - expect(error.status).toBe('error'); - expect(error.message).toBeDefined(); + expect(error.eventName).toBe(socketEvents.CREATE_ROOM); done(); }); client1.emit(socketEvents.CREATE_ROOM, config); }); }); + }); - it('방생성시 서버는 올바른 6자리 숫자(PIN)을 응답해야한다.', async () => { - // Promise와 함께 once 사용 - const response = await new Promise<{ gameId: string }>((resolve) => { - // CREATE_ROOM 이벤트의 응답을 한 번만 기다림 + describe('joinRoom 이벤트 테스트', () => { + it('존재하는 방 참여 성공', async () => { + // 방 생성 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { client1.once(socketEvents.CREATE_ROOM, resolve); - - // 이벤트 발생 client1.emit(socketEvents.CREATE_ROOM, { title: 'Test Room', gameMode: 'RANKING', @@ -136,155 +197,183 @@ describe('GameGateway (e2e)', () => { }); }); - // 6자리 숫자 검증 - expect(response.gameId).toBeDefined(); - expect(response.gameId).toMatch(/^\d{6}$/); // 정확히 6자리 숫자만 + // 방 참여 + const joinResponse = await new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, resolve); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'TestPlayer' + }); + }); + + expect(joinResponse.players).toBeDefined(); + + // Redis에서 플레이어 정보 확인 + const playerData = await redisMock.hgetall(`Player:${client2.id}`); + expect(playerData).toBeDefined(); + expect(playerData.playerName).toBe('TestPlayer'); + }); - // 범위 검증 (100000-999999) - const pinNumber = parseInt(response.gameId); - expect(pinNumber).toBeGreaterThanOrEqual(100000); - expect(pinNumber).toBeLessThanOrEqual(999999); + it('존재하지 않는 방 참여 실패', (done) => { + client1.once('exception', (error) => { + expect(error.eventName).toBe('joinRoom'); + expect(error.message).toBe('존재하지 않는 게임 방입니다.'); + done(); + }); + + client1.emit(socketEvents.JOIN_ROOM, { + gameId: '999999', + playerName: 'TestPlayer' + }); }); }); describe('chatMessage 이벤트 테스트', () => { - it('같은 Room의 플레이어들에게 브로드캐스팅 성공', async () => { - /*given*/ - // 게임방 생성 로직 - const createRoomResponse = await new Promise<{ gameId: string }>((resolve) => { + it('같은 방의 모든 플레이어에게 메시지 전송', async () => { + // 방 생성 및 참여 설정 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { client1.once(socketEvents.CREATE_ROOM, resolve); client1.emit(socketEvents.CREATE_ROOM, { - title: 'hello world!', + title: 'Chat Test Room', gameMode: 'RANKING', maxPlayerCount: 5, isPublicGame: true }); }); - // 게임방 참여 로직 - const joinRoomResponse1 = await new Promise((resolve) => { - client1.once(socketEvents.JOIN_ROOM, resolve); - client1.emit(socketEvents.JOIN_ROOM, { - gameId: createRoomResponse.gameId, - playerName: '시크릿주주1' - }); - }); - const joinRoomResponse2 = await new Promise((resolve) => { - client2.once(socketEvents.JOIN_ROOM, resolve); - client2.emit(socketEvents.JOIN_ROOM, { - gameId: createRoomResponse.gameId, - playerName: '시크릿주주2' - }); - }); - const joinRoomResponse3 = await new Promise((resolve) => { - client3.once(socketEvents.JOIN_ROOM, resolve); - client3.emit(socketEvents.JOIN_ROOM, { - gameId: createRoomResponse.gameId, - playerName: '시크릿주주3' - }); + // 플레이어들 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player1' + }); + }), + new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, () => resolve()); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player2' + }); + }) + ]); + + // 채팅 메시지 테스트 + const testMessage = 'Hello, everyone!'; + const messagePromises = [ + new Promise((resolve) => client1.once(socketEvents.CHAT_MESSAGE, resolve)), + new Promise((resolve) => client2.once(socketEvents.CHAT_MESSAGE, resolve)) + ]; + + client1.emit(socketEvents.CHAT_MESSAGE, { + gameId: createResponse.gameId, + message: testMessage }); - const messageToSend = '안녕하세요! 여러분!'; - const receivedCount = 0; - const chatMessageResponse = await new Promise((resolve) => { - const responseList = []; - client1.once(socketEvents.CHAT_MESSAGE, (payload) => { - responseList.push(payload); - if (responseList.length === 3) { - resolve(responseList); - } - }); - client2.once(socketEvents.CHAT_MESSAGE, (payload) => { - responseList.push(payload); - if (responseList.length === 3) { - resolve(responseList); - } - }); - client3.once(socketEvents.CHAT_MESSAGE, (payload) => { - responseList.push(payload); - if (responseList.length === 3) { - resolve(responseList); - } - }); - client1.emit(socketEvents.CHAT_MESSAGE, { - gameId: createRoomResponse.gameId, - message: messageToSend - }); + const receivedMessages = await Promise.all(messagePromises); + receivedMessages.forEach((msg) => { + expect(msg.message).toBe(testMessage); + expect(msg.playerName).toBe('Player1'); }); - - expect(chatMessageResponse[0].message).toBe(messageToSend); }); }); describe('updatePosition 이벤트 테스트', () => { - it('유효한 설정을주면 위치 업데이트를 성공 해야 한다.', async () => { - const createRoomResponse = await new Promise<{ gameId: string }>((resolve) => { + it('위치 업데이트 성공', async () => { + // 방 생성 및 참여 설정 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { client1.once(socketEvents.CREATE_ROOM, resolve); client1.emit(socketEvents.CREATE_ROOM, { - title: 'hello world!', + title: 'Chat Test Room', gameMode: 'RANKING', maxPlayerCount: 5, isPublicGame: true }); }); - const joinRoomResponse = await new Promise((resolve) => { - client1.once(socketEvents.JOIN_ROOM, resolve); - client1.emit(socketEvents.JOIN_ROOM, { - gameId: createRoomResponse.gameId, - playerName: '시크릿주주1' - }); - }); - - const newPosition = [1, 1]; - const updatePositionResponse = await new Promise((resolve) => { + // 플레이어들 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player1' + }); + }), + new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, () => resolve()); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player2' + }); + }) + ]); + const newPosition = [0.2, 0.5]; + + const updateResponse = await new Promise((resolve) => { client1.once(socketEvents.UPDATE_POSITION, resolve); client1.emit(socketEvents.UPDATE_POSITION, { - gameId: createRoomResponse.gameId, - newPosition: newPosition + gameId: createResponse.gameId, + newPosition }); }); - expect(updatePositionResponse).toBeDefined(); - expect(updatePositionResponse.playerId).toBe(client1.id); - expect(updatePositionResponse.playerPosition).toEqual(newPosition); - }); + expect(updateResponse.playerPosition).toEqual(newPosition); - it('update DTO에 잘못된값이 있는경우 error를 내야한다.', (done) => { - client1.once('exception', (error) => { - expect(error).toBeDefined(); - expect(error.message).toBe('PIN번호는 6자리이어야 합니다.'); - done(); - }); - - client1.emit(socketEvents.UPDATE_POSITION, { - gameId: '', - newPosition: [] - }); + // Redis에서 위치 정보 확인 + const playerData = await redisMock.hgetall(`Player:${client1.id}`); + expect(parseFloat(playerData.positionX)).toBe(newPosition[0]); + expect(parseFloat(playerData.positionY)).toBe(newPosition[1]); }); + }); - it('해당방의 플레이어가 아닌경우 error를 내야한다.', async () => { - client2.once('error', (error) => { - expect(error).toBeDefined(); - expect(error).toBe('[ERROR] 해당 게임 방의 플레이어가 아닙니다.'); - }); - - const createRoomResponse = await new Promise<{ gameId: string }>((resolve) => { + describe('startGame 이벤트 테스트', () => { + it('게임 시작할 때 초기 설정 성공', async () => { + // 방 생성 및 참여 설정 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { client1.once(socketEvents.CREATE_ROOM, resolve); client1.emit(socketEvents.CREATE_ROOM, { - title: 'hello world!', + title: 'Chat Test Room', gameMode: 'RANKING', maxPlayerCount: 5, isPublicGame: true }); }); - const newPosition = [1, 1]; - - client2.emit(socketEvents.UPDATE_POSITION, { - gameId: createRoomResponse.gameId, - newPosition: newPosition + // 플레이어들 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player1' + }); + }), + new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, () => resolve()); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player2' + }); + }) + ]); + const gameId = createResponse.gameId; + + client1.emit(socketEvents.START_GAME, { + gameId }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); // 1.5초 대기 + + const quizSetIds = await redisMock.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + + // 내림차순 조회 (높은 점수부터) + const leaderboard = await redisMock.zrevrange(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, -1); + + expect(gameId).toBe(createResponse.gameId); + expect(quizSetIds.length).toBeGreaterThan(0); // FIX: 추후 더 fit하게 바꾸기 + expect(leaderboard).toBeDefined(); }); }); }); diff --git a/BE/test/mocks/custom-redis.mock.ts b/BE/test/mocks/custom-redis.mock.ts new file mode 100644 index 0000000..7b37e71 --- /dev/null +++ b/BE/test/mocks/custom-redis.mock.ts @@ -0,0 +1,14 @@ +// import RedisMock from 'ioredis-mock'; +// +// export class CustomRedisMock extends RedisMock { +// constructor() { +// super(); +// } +// +// // @ts-ignore +// async hset(key: string, value: string): Promise { +// await super.hset(key, value); +// console.log(key); +// await super.publish(`__keyspace@0__:${key}`, 'hset'); +// } +// } diff --git a/BE/test/mocks/quiz-data.mock.ts b/BE/test/mocks/quiz-data.mock.ts new file mode 100644 index 0000000..f7d1cc7 --- /dev/null +++ b/BE/test/mocks/quiz-data.mock.ts @@ -0,0 +1,61 @@ +export const mockQuizData = { + id: '1', + title: '재미있는 상식 퀴즈', + category: 'common', + quizList: [ + { + id: '1', + quiz: '다음 중 대한민국의 수도는?', + limitTime: 30, + choiceList: [ + { + content: '서울', + order: 1, + isAnswer: true + }, + { + content: '부산', + order: 2, + isAnswer: false + }, + { + content: '인천', + order: 3, + isAnswer: false + }, + { + content: '대구', + order: 4, + isAnswer: false + } + ] + }, + { + id: '2', + quiz: '1 + 1 = ?', + limitTime: 20, + choiceList: [ + { + content: '1', + order: 1, + isAnswer: false + }, + { + content: '2', + order: 2, + isAnswer: true + }, + { + content: '3', + order: 3, + isAnswer: false + }, + { + content: '4', + order: 4, + isAnswer: false + } + ] + } + ] +}; diff --git a/BE/test/quiz.e2e-spec.ts b/BE/test/quiz.e2e-spec.ts new file mode 100644 index 0000000..3f37657 --- /dev/null +++ b/BE/test/quiz.e2e-spec.ts @@ -0,0 +1,385 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DataSource, QueryRunner } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { QuizService } from '../src/quiz/quiz.service'; +import { QuizSetModel } from '../src/quiz/entities/quiz-set.entity'; +import { QuizModel } from '../src/quiz/entities/quiz.entity'; +import { QuizChoiceModel } from '../src/quiz/entities/quiz-choice.entity'; +import { UserModel } from '../src/user/entities/user.entity'; +import { UserQuizArchiveModel } from '../src/user/entities/user-quiz-archive.entity'; +import { CreateQuizSetDto } from '../src/quiz/dto/create-quiz.dto'; + +describe('QuizService', () => { + let quizService: QuizService; + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '../.env', + isGlobal: true + }), + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST_TEST || process.env.DB_HOST || '127.0.0.1', + port: +process.env.DB_PORT_TEST || +process.env.DB_PORT || 3306, + username: process.env.DB_USER_TEST || process.env.DB_USER || 'root', + password: process.env.DB_PASSWD_TEST || process.env.DB_PASSWD || 'test', + database: process.env.DB_NAME_TEST || process.env.DB_NAME || 'test_db', + entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], + synchronize: true // test모드에서는 항상 활성화 + // logging: true, // 모든 쿼리 로깅 + // logger: 'advanced-console' + // extra: { + // // 글로벌 batch size 설정 + // maxBatchSize: 100 + // } + }), + TypeOrmModule.forFeature([QuizSetModel, QuizModel, QuizChoiceModel, UserModel]) + ], + providers: [QuizService] + }).compile(); + + quizService = module.get(QuizService); + dataSource = module.get(DataSource); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + }); + + afterEach(async () => { + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + it('should be defined', () => { + expect(quizService).toBeDefined(); + expect(dataSource).toBeDefined(); + }); + + describe('createQuizSet', () => { + it('퀴즈셋을 성공적으로 생성해야 한다', async () => { + // Given + const createQuizSetDto: CreateQuizSetDto = { + title: '자바스크립트 기초', + category: 'PROGRAMMING', + quizList: [ + { + quiz: 'JavaScript의 원시 타입이 아닌 것은?', + limitTime: 30, + choiceList: [ + { + choiceContent: 'String', + choiceOrder: 1, + isAnswer: false + }, + { + choiceContent: 'Array', + choiceOrder: 2, + isAnswer: true + }, + { + choiceContent: 'Number', + choiceOrder: 3, + isAnswer: false + } + ] + } + ] + }; + + // When + const result = await quizService.createQuizSet(createQuizSetDto); + + // Then + expect(result).toBeDefined(); + expect(result.data.id).toBeDefined(); + + // 데이터가 정상적으로 저장되었는지 확인 + const savedQuizSet = await queryRunner.manager.findOne(QuizSetModel, { + where: { id: result.data.id }, + relations: ['quizList', 'quizList.choiceList', 'user'] + }); + + expect(savedQuizSet).toBeDefined(); + expect(savedQuizSet.title).toBe(createQuizSetDto.title); + // expect(savedQuizSet.user.email).toBe('honux@codesquad.co.kr'); + expect(savedQuizSet.quizList).toHaveLength(1); + expect(savedQuizSet.quizList[0].choiceList).toHaveLength(3); + }); + + it('정답이 없는 퀴즈셋 생성 시 에러가 발생해야 한다', async () => { + // Given + const invalidQuizSetDto: CreateQuizSetDto = { + title: '잘못된 퀴즈셋', + category: 'PROGRAMMING', + quizList: [ + { + quiz: '문제', + limitTime: 30, + choiceList: [ + { + choiceContent: '보기1', + choiceOrder: 1, + isAnswer: false + }, + { + choiceContent: '보기2', + choiceOrder: 2, + isAnswer: false + } + ] + } + ] + }; + + // When & Then + await expect(quizService.createQuizSet(invalidQuizSetDto)).rejects.toThrow( + BadRequestException + ); + }); + + it('선택지 번호가 중복된 퀴즈셋 생성 시 에러가 발생해야 한다', async () => { + // Given + const duplicateOrderQuizSetDto: CreateQuizSetDto = { + title: '중복 번호 퀴즈셋', + category: 'PROGRAMMING', + quizList: [ + { + quiz: '문제', + limitTime: 30, + choiceList: [ + { + choiceContent: '보기1', + choiceOrder: 1, + isAnswer: true + }, + { + choiceContent: '보기2', + choiceOrder: 1, + isAnswer: false + } + ] + } + ] + }; + + // When & Then + await expect(quizService.createQuizSet(duplicateOrderQuizSetDto)).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe('findAllWithQuizzesAndChoices', () => { + it('카테고리별 퀴즈셋 목록을 가져와야한다', async () => { + // Given - 테스트 데이터 생성 + const createQuizSetDto: CreateQuizSetDto = { + title: '자바스크립트 기초', + category: 'PROGRAMMING', + quizList: [ + { + quiz: '테스트 문제', + limitTime: 30, + choiceList: [ + { + choiceContent: '보기1', + choiceOrder: 1, + isAnswer: true + }, + { + choiceContent: '보기2', + choiceOrder: 2, + isAnswer: false + } + ] + } + ] + }; + + await quizService.createQuizSet(createQuizSetDto); + + // When + const result = await quizService.findAllWithQuizzesAndChoices('PROGRAMMING', 0, 10); + + // Then + expect(result.quizSetList).toBeDefined(); + expect(result.quizSetList[0].category).toBe('PROGRAMMING'); + expect(result.quizSetList[0].quizList).toHaveLength(1); + expect(result.quizSetList[0].quizList[0].choiceList).toHaveLength(2); + }); + + it('존재하지 않는 카테고리는 빈 배열을 반환해야 한다', async () => { + // When + const result = await quizService.findAllWithQuizzesAndChoices('INVALID', 0, 10); + + // Then + expect(result.quizSetList).toHaveLength(0); + }); + }); + + describe('findOne', () => { + let testQuizSet; + + beforeEach(async () => { + // Given - 테스트 데이터 생성 + const dto = { + title: '테스트 퀴즈', + category: 'TEST', + quizList: [ + { + quiz: '문제1', + limitTime: 30, + choiceList: [ + { + choiceContent: '보기1', + choiceOrder: 1, + isAnswer: true + } + ] + } + ] + }; + + const result = await quizService.createQuizSet(dto); + testQuizSet = result.data; + }); + + it('ID로 퀴즈셋을 찾을 수 있어야 한다.', async () => { + // When + const result = await quizService.findOne(testQuizSet.id); + + // Then + expect(result).toBeDefined(); + expect(result.id).toBe(testQuizSet.id.toString()); + expect(result.quizList).toHaveLength(1); + expect(result.quizList[0].choiceList).toHaveLength(1); + }); + + it('존재하지 않는 ID로 조회시 에러가 발생해야 한다.', async () => { + // When & Then + await expect(quizService.findOne(999999)).rejects.toThrow(); + }); + }); + + describe('update', () => { + let originQuizSetId; + + beforeEach(async () => { + // Given - 테스트용 퀴즈셋 생성 + const dto = { + title: '원본 퀴즈', + category: 'TEST', + quizList: [ + { + quiz: '원본 문제', + limitTime: 30, + choiceList: [ + { + choiceContent: '원본 보기', + choiceOrder: 1, + isAnswer: true + } + ] + } + ] + }; + + const result = await quizService.createQuizSet(dto); + originQuizSetId = result.data.id; + }); + + it('퀴즈셋을 수정할 수 있어야 한다.', async () => { + // Given + const updateDto = { + title: '수정된 퀴즈', + category: 'UPDATED', + quizList: [ + { + quiz: '수정된 문제', + limitTime: 60, + choiceList: [ + { + choiceContent: '수정된 보기', + choiceOrder: 1, + isAnswer: true + } + ] + } + ] + }; + + // When + await quizService.update(originQuizSetId, updateDto); + + // Then + const updated = await quizService.findOne(originQuizSetId); + console.log('updated: ' + JSON.stringify(updated)); + expect(updated.id).toBe(originQuizSetId.toString()); + expect(updated.title).toBe('수정된 퀴즈'); + expect(updated.category).toBe('UPDATED'); + expect(updated.quizList[0].quiz).toBe('수정된 문제'); + expect(updated.quizList[0].limitTime).toBe(60); + }); + + it('존재하지 않는 퀴즈셋 수정시 에러가 발생해야 한다.', async () => { + // When & Then + await expect(quizService.update(999999, { title: 'test' })).rejects.toThrow(); + }); + }); + + describe('remove', () => { + let testQuizSet; + + beforeEach(async () => { + // Given - 테스트용 퀴즈셋 생성 + const dto = { + title: '삭제될 퀴즈', + category: 'TEST', + quizList: [ + { + quiz: '문제', + limitTime: 30, + choiceList: [ + { + choiceContent: '보기', + choiceOrder: 1, + isAnswer: true + } + ] + } + ] + }; + + const result = await quizService.createQuizSet(dto); + testQuizSet = result.data; + }); + + it('퀴즈셋을 soft delete 할 수 있어야 한다.', async () => { + // When + const result = await quizService.remove(testQuizSet.id); + + // Then + expect(result.success).toBe(true); + + // soft delete 되었는지 확인 (일반 조회시 조회 안됨) + await expect(quizService.findOne(testQuizSet.id)).rejects.toThrow(); + }); + + it('존재하지 않는 퀴즈셋 삭제시 에러가 발생해야 한다.', async () => { + // When & Then + await expect(quizService.remove(999999)).rejects.toThrow(); + }); + }); +});