├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── Procfile ├── README.md ├── assets └── videos │ ├── NestJS + MSA 한큐에 알려드립니다.mp4 │ └── [앱개발] [Flutter] Provider 로 Optimistic Response Cache 관리!.mp4 ├── jest-e2e.json ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── prisma └── schema │ ├── migrations │ ├── 20241001113714_init │ │ └── migration.sql │ ├── 20241002144638_movie_model_update │ │ └── migration.sql │ └── migration_lock.toml │ └── schema.prisma ├── src ├── app.module.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── decorator │ │ ├── authorization.decorator.ts │ │ ├── public.decorator.ts │ │ └── rbac.decorator.ts │ ├── guard │ │ ├── auth.guard.ts │ │ └── rbac.guard.ts │ ├── middleware │ │ └── bearer-token.middleware.ts │ └── strategy │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts ├── chat │ ├── chat.gateway.spec.ts │ ├── chat.gateway.ts │ ├── chat.module.ts │ ├── chat.service.spec.ts │ ├── chat.service.ts │ ├── dto │ │ └── create-chat.dto.ts │ ├── entity │ │ ├── chat-room.entity.ts │ │ └── chat.entity.ts │ └── schema │ │ ├── chat-room.schema.ts │ │ └── chat.schema.ts ├── common │ ├── common.controller.spec.ts │ ├── common.controller.ts │ ├── common.module.ts │ ├── common.service.ts │ ├── const │ │ └── env.const.ts │ ├── decorator │ │ ├── query-runner.decorator.ts │ │ ├── throttle.decorator.ts │ │ └── ws-query-runner.decorator.ts │ ├── dto │ │ ├── cursor-pagination.dto.ts │ │ └── page-pagination.dto.ts │ ├── entity │ │ └── base-table.entity.ts │ ├── filter │ │ ├── forbidden.filter.ts │ │ └── query-failed.filter.ts │ ├── interceptor │ │ ├── cache.interceptor.ts │ │ ├── response-time.interceptor.ts │ │ ├── throttle.interceptor.ts │ │ ├── transaction.interceptor.ts │ │ └── ws-transaction.interceptor.ts │ ├── logger │ │ └── default.logger.ts │ ├── prisma.service.ts │ └── tasks.service.ts ├── database │ ├── data-source.ts │ └── migrations │ │ ├── 1726493656200-init.ts │ │ └── 1726493905165-test.ts ├── director │ ├── director.controller.spec.ts │ ├── director.controller.ts │ ├── director.module.ts │ ├── director.service.spec.ts │ ├── director.service.ts │ ├── dto │ │ ├── create-director.dto.ts │ │ └── update-director.dto.ts │ ├── entity │ │ └── director.entity.ts │ └── schema │ │ └── director.schema.ts ├── genre │ ├── dto │ │ ├── create-genre.dto.ts │ │ └── update-genre.dto.ts │ ├── entity │ │ └── genre.entity.ts │ ├── genre.controller.spec.ts │ ├── genre.controller.ts │ ├── genre.module.ts │ ├── genre.service.spec.ts │ ├── genre.service.ts │ └── schema │ │ └── genre.schema.ts ├── main.ts ├── movie │ ├── dto │ │ ├── create-movie.dto.ts │ │ ├── get-movies.dto.ts │ │ └── update-movie.dto.ts │ ├── entity │ │ ├── movie-detail.entity.ts │ │ ├── movie-user-like.entity.ts │ │ └── movie.entity.ts │ ├── movie.controller.spec.ts │ ├── movie.controller.ts │ ├── movie.e2e.spec.ts │ ├── movie.module.ts │ ├── movie.service.integration.spec.ts │ ├── movie.service.spec.ts │ ├── movie.service.ts │ ├── pipe │ │ ├── movie-file.pipe.ts │ │ └── movie-title-validation.pipe.ts │ └── schema │ │ ├── movie-detail.schema.ts │ │ ├── movie-user-like.schema.ts │ │ └── movie.schema.ts ├── user │ ├── decorator │ │ └── user-id.decorator.ts │ ├── dto │ │ ├── create-user.dto.ts │ │ └── update-user.dto.ts │ ├── entity │ │ └── user.entity.ts │ ├── schema │ │ └── user.schema.ts │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.module.ts │ ├── user.service.spec.ts │ └── user.service.ts └── worker │ ├── thumbnail-generation.worker.ts │ └── worker.module.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to AWS Elastic Beanstalk 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | postgres: 14 | image: postgres:16 15 | env: 16 | POSTGRES_USER: postgres 17 | POSTGRES_PASSWORD: postgres 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 5432:5432 25 | 26 | steps: 27 | - name: Checkout Code 28 | uses: actions/checkout@v3 29 | 30 | - name: Set Up NodeJS 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: '18' 34 | 35 | - name: Create Env File 36 | env: 37 | ENV: ${{secrets.ENV}} 38 | DB_TYPE: ${{secrets.DB_TYPE}} 39 | DB_HOST: ${{secrets.DB_HOST}} 40 | DB_PORT: ${{secrets.DB_PORT}} 41 | DB_USERNAME: ${{secrets.DB_USERNAME}} 42 | DB_PASSWORD: ${{secrets.DB_PASSWORD}} 43 | DB_DATABASE: ${{secrets.DB_DATABASE}} 44 | HASH_ROUNDS: ${{secrets.HASH_ROUNDS}} 45 | ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}} 46 | REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}} 47 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 48 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 49 | AWS_REGION: ${{secrets.AWS_REGION}} 50 | BUCKET_NAME: ${{secrets.BUCKET_NAME}} 51 | run: | 52 | touch test.env 53 | echo ENV="test" >> test.env 54 | echo DB_TYPE="$DB_TYPE" >> test.env 55 | echo DB_HOST="localhost" >> test.env 56 | echo DB_PORT="$DB_PORT" >> test.env 57 | echo DB_USERNAME="$DB_USERNAME" >> test.env 58 | echo DB_PASSWORD="$DB_PASSWORD" >> test.env 59 | echo DB_DATABASE="$DB_DATABASE" >> test.env 60 | echo HASH_ROUNDS="$HASH_ROUNDS" >> test.env 61 | echo ACCESS_TOKEN_SECRET="$ACCESS_TOKEN_SECRET" >> test.env 62 | echo REFRESH_TOKEN_SECRET="$REFRESH_TOKEN_SECRET" >> test.env 63 | echo AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" >> test.env 64 | echo AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" >> test.env 65 | echo AWS_REGION="$AWS_REGION" >> test.env 66 | echo BUCKET_NAME="$BUCKET_NAME" >> test.env 67 | echo "test.env created" 68 | cat test.env 69 | 70 | touch env.env 71 | echo ENV="$ENV" >> .env 72 | echo DB_TYPE="$DB_TYPE" >> .env 73 | echo DB_HOST="$DB_HOST" >> .env 74 | echo DB_PORT="$DB_PORT" >> .env 75 | echo DB_USERNAME="$DB_USERNAME" >> .env 76 | echo DB_PASSWORD="$DB_PASSWORD" >> .env 77 | echo DB_DATABASE="$DB_DATABASE" >> .env 78 | echo HASH_ROUNDS="$HASH_ROUNDS" >> .env 79 | echo ACCESS_TOKEN_SECRET="$ACCESS_TOKEN_SECRET" >> .env 80 | echo REFRESH_TOKEN_SECRET="$REFRESH_TOKEN_SECRET" >> .env 81 | echo AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" >> .env 82 | echo AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" >> .env 83 | echo AWS_REGION="$AWS_REGION" >> .env 84 | echo BUCKET_NAME="$BUCKET_NAME" >> .env 85 | echo ".env created" 86 | cat .env 87 | 88 | - name: Create Folders 89 | run: | 90 | mkdir -p ./public/movie 91 | mkdir -p ./public/temp 92 | 93 | - name: Install Depencies 94 | run: npm i 95 | 96 | - name: Build Project 97 | run: npm run build 98 | 99 | - name: Run Test 100 | run: npm run test 101 | 102 | - name: Install Typeorm 103 | run: npm i -g typeorm 104 | 105 | - name: Run Migration 106 | run: typeorm migration:run -d ./dist/database/data-source.js 107 | 108 | - name: Zip Artfact For Deployment 109 | run: zip -r deploy.zip . 110 | 111 | - name: Upload To S3 112 | env: 113 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 114 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 115 | AWS_REGION: ${{secrets.AWS_REGION}} 116 | run: | 117 | aws configure set region $AWS_REGION 118 | aws s3 cp deploy.zip s3://nestjs-netflix-bucket/deploy.zip 119 | 120 | - name: Deploy To AWS Elastic Beanstalk 121 | env: 122 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 123 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 124 | AWS_REGION: ${{secrets.AWS_REGION}} 125 | run: | 126 | aws elasticbeanstalk create-application-version \ 127 | --application-name "nestjs-netflix" \ 128 | --version-label $GITHUB_SHA \ 129 | --source-bundle S3Bucket="nestjs-netflix-bucket",S3Key="deploy.zip" 130 | 131 | aws elasticbeanstalk update-environment \ 132 | --application-name "nestjs-netflix" \ 133 | --environment-name "Nestjs-netflix-env" \ 134 | --version-label $GITHUB_SHA -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | public 38 | assets 39 | 40 | .env 41 | *.env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Nest Framework", 8 | "runtimeExecutable": "pnpm", 9 | "runtimeArgs": [ 10 | "run", 11 | "start:debug", 12 | "--", 13 | "--inspect-brk" 14 | ], 15 | "autoAttachChildProcesses": true, 16 | "restart": true, 17 | "sourceMaps": true, 18 | "stopOnEntry": false, 19 | "console": "integratedTerminal", 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start:prod -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ pnpm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ pnpm run start 40 | 41 | # watch mode 42 | $ pnpm run start:dev 43 | 44 | # production mode 45 | $ pnpm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ pnpm run test 53 | 54 | # e2e tests 55 | $ pnpm run test:e2e 56 | 57 | # test coverage 58 | $ pnpm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /assets/videos/NestJS + MSA 한큐에 알려드립니다.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefactory-co/fastcampus-nestjs-part-1/d55e7d4e9fbfa92b2e171b61704afb7388e58eee/assets/videos/NestJS + MSA 한큐에 알려드립니다.mp4 -------------------------------------------------------------------------------- /assets/videos/[앱개발] [Flutter] Provider 로 Optimistic Response Cache 관리!.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefactory-co/fastcampus-nestjs-part-1/d55e7d4e9fbfa92b2e171b61704afb7388e58eee/assets/videos/[앱개발] [Flutter] Provider 로 Optimistic Response Cache 관리!.mp4 -------------------------------------------------------------------------------- /jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "roots": [ 8 | "src" 9 | ], 10 | "testEnvironment": "node", 11 | "testRegex": ".e2e.spec.ts$", 12 | "transform": { 13 | "^.+\\.(t|j)s$": "ts-jest" 14 | }, 15 | "moduleNameMapper": { 16 | "src/(.*)": "/src/$1" 17 | } 18 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "plugins": ["@nestjs/swagger"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netflix", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:dev:worker": "export TYPE=worker && export PORT=3001 && nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "NODE_ENV=test jest", 18 | "test:watch": "jest --watch", 19 | "test:integration": "jest --testRegex='.*\\integration.spec\\.ts$'", 20 | "test:cov": "jest --coverage", 21 | "test:cov:watch": "jest --watchAll --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "NODE_ENV=test jest --config ./jest-e2e.json", 24 | "test:user": "jest --testPathPattern=src/user --coverage --collectCoverageFrom=src/user/**", 25 | "test:user:watch": "jest --watch --testPathPattern=src/user --coverage --collectCoverageFrom=src/user/**", 26 | "test:auth": "jest --testPathPattern=src/auth --coverage --collectCoverageFrom=src/auth/**", 27 | "test:auth:watch": "jest --watch --testPathPattern=src/auth --coverage --collectCoverageFrom=src/auth/**", 28 | "test:director": "jest --testPathPattern=src/director --coverage --collectCoverageFrom=src/director/**", 29 | "test:director:watch": "jest --watch --testPathPattern=src/director --coverage --collectCoverageFrom=src/director/**", 30 | "test:genre": "jest --testPathPattern=src/genre --coverage --collectCoverageFrom=src/genre/**", 31 | "test:genre:watch": "jest --watch --testPathPattern=src/genre --coverage --collectCoverageFrom=src/genre/**" 32 | }, 33 | "dependencies": { 34 | "@aws-sdk/client-s3": "^3.651.1", 35 | "@aws-sdk/s3-request-presigner": "^3.651.1", 36 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 37 | "@nestjs/bull": "^10.2.1", 38 | "@nestjs/bullmq": "^10.2.1", 39 | "@nestjs/cache-manager": "^2.2.2", 40 | "@nestjs/common": "^10.0.0", 41 | "@nestjs/config": "^3.2.3", 42 | "@nestjs/core": "^10.0.0", 43 | "@nestjs/jwt": "^10.2.0", 44 | "@nestjs/mapped-types": "*", 45 | "@nestjs/mongoose": "^10.0.10", 46 | "@nestjs/passport": "^10.0.3", 47 | "@nestjs/platform-express": "^10.0.0", 48 | "@nestjs/platform-socket.io": "^10.4.3", 49 | "@nestjs/schedule": "^4.1.0", 50 | "@nestjs/serve-static": "^4.0.2", 51 | "@nestjs/swagger": "^7.4.0", 52 | "@nestjs/typeorm": "^10.0.2", 53 | "@nestjs/websockets": "^10.4.3", 54 | "@prisma/client": "5.20.0", 55 | "@types/multer": "^1.4.11", 56 | "@types/passport-local": "^1.0.38", 57 | "aws-sdk": "^2.1691.0", 58 | "bcrypt": "^5.1.1", 59 | "bullmq": "^5.13.2", 60 | "cache-manager": "^5.7.6", 61 | "class-transformer": "^0.5.1", 62 | "class-validator": "^0.14.1", 63 | "dotenv": "^16.4.5", 64 | "express-session": "^1.18.0", 65 | "ffprobe-static": "^3.1.0", 66 | "fluent-ffmpeg": "^2.1.3", 67 | "joi": "^17.13.3", 68 | "mongoose": "^8.7.0", 69 | "multer": "1.4.5-lts.1", 70 | "nest-winston": "^1.9.7", 71 | "passport": "^0.7.0", 72 | "passport-jwt": "^4.0.1", 73 | "passport-local": "^1.0.0", 74 | "pg": "^8.12.0", 75 | "reflect-metadata": "^0.1.13", 76 | "rxjs": "^7.8.1", 77 | "socket.io": "^4.7.5", 78 | "typeorm": "^0.3.20", 79 | "uuid": "^10.0.0", 80 | "winston": "^3.14.2" 81 | }, 82 | "devDependencies": { 83 | "@automock/adapters.nestjs": "^2.1.0", 84 | "@automock/jest": "^2.1.0", 85 | "@nestjs/cli": "^10.0.0", 86 | "@nestjs/schematics": "^10.0.0", 87 | "@nestjs/testing": "^10.0.0", 88 | "@types/bcrypt": "^5.0.2", 89 | "@types/express": "^4.17.17", 90 | "@types/express-session": "^1.18.0", 91 | "@types/jest": "^29.5.2", 92 | "@types/node": "^20.3.1", 93 | "@types/passport-jwt": "^4.0.1", 94 | "@types/supertest": "^2.0.12", 95 | "@types/winston": "^2.4.4", 96 | "@typescript-eslint/eslint-plugin": "^6.0.0", 97 | "@typescript-eslint/parser": "^6.0.0", 98 | "eslint": "^8.42.0", 99 | "eslint-config-prettier": "^9.0.0", 100 | "eslint-plugin-prettier": "^5.0.0", 101 | "jest": "^29.5.0", 102 | "prettier": "^3.0.0", 103 | "prisma": "5.20.0", 104 | "source-map-support": "^0.5.21", 105 | "sqlite3": "^5.1.7", 106 | "supertest": "^6.3.3", 107 | "ts-jest": "^29.1.0", 108 | "ts-loader": "^9.4.3", 109 | "ts-node": "^10.9.1", 110 | "tsconfig-paths": "^4.2.0", 111 | "typescript": "^5.1.3" 112 | }, 113 | "jest": { 114 | "moduleFileExtensions": [ 115 | "js", 116 | "json", 117 | "ts" 118 | ], 119 | "roots": [ 120 | "src" 121 | ], 122 | "testRegex": ".*\\.spec\\.ts$", 123 | "transform": { 124 | "^.+\\.(t|j)s$": "ts-jest" 125 | }, 126 | "collectCoverageFrom": [ 127 | "**/*.(t|j)s" 128 | ], 129 | "coveragePathIgnorePatterns": [ 130 | "module.ts", 131 | "dto.ts", 132 | "entity.ts", 133 | "guard.ts", 134 | "middleware.ts", 135 | "strategy.ts", 136 | "decorator.ts", 137 | "pipe.ts", 138 | "common/*", 139 | "main.ts" 140 | ], 141 | "coverageDirectory": "./coverage", 142 | "testEnvironment": "node", 143 | "moduleNameMapper": { 144 | "src/(.*)": "/src/$1" 145 | } 146 | }, 147 | "packageManager": "pnpm@9.1.1+sha1.09ada6cd05003e0ced25fb716f9fda4063ec2e3b" 148 | } -------------------------------------------------------------------------------- /prisma/schema/migrations/20241001113714_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('admin', 'paidUser', 'user'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "User" ( 6 | "id" SERIAL NOT NULL, 7 | "email" TEXT NOT NULL, 8 | "password" TEXT NOT NULL, 9 | "role" "Role" NOT NULL DEFAULT 'user', 10 | 11 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "Movie" ( 16 | "id" SERIAL NOT NULL, 17 | "creatorId" INTEGER NOT NULL, 18 | "title" TEXT NOT NULL, 19 | "likeCount" INTEGER NOT NULL DEFAULT 0, 20 | "dislikeCount" INTEGER NOT NULL DEFAULT 0, 21 | "detailId" INTEGER NOT NULL, 22 | "movieFilePath" TEXT NOT NULL, 23 | "directorId" INTEGER NOT NULL, 24 | 25 | CONSTRAINT "Movie_pkey" PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "MovieUserLike" ( 30 | "movieId" INTEGER NOT NULL, 31 | "userId" INTEGER NOT NULL, 32 | "isLike" BOOLEAN NOT NULL, 33 | 34 | CONSTRAINT "MovieUserLike_pkey" PRIMARY KEY ("movieId","userId") 35 | ); 36 | 37 | -- CreateTable 38 | CREATE TABLE "MovieDetail" ( 39 | "id" SERIAL NOT NULL, 40 | "detail" TEXT NOT NULL, 41 | 42 | CONSTRAINT "MovieDetail_pkey" PRIMARY KEY ("id") 43 | ); 44 | 45 | -- CreateTable 46 | CREATE TABLE "Chat" ( 47 | "id" SERIAL NOT NULL, 48 | "authorId" INTEGER NOT NULL, 49 | "message" TEXT NOT NULL, 50 | "chatRoomId" INTEGER NOT NULL, 51 | 52 | CONSTRAINT "Chat_pkey" PRIMARY KEY ("id") 53 | ); 54 | 55 | -- CreateTable 56 | CREATE TABLE "ChatRoom" ( 57 | "id" SERIAL NOT NULL, 58 | 59 | CONSTRAINT "ChatRoom_pkey" PRIMARY KEY ("id") 60 | ); 61 | 62 | -- CreateTable 63 | CREATE TABLE "Genre" ( 64 | "id" SERIAL NOT NULL, 65 | "name" TEXT NOT NULL, 66 | 67 | CONSTRAINT "Genre_pkey" PRIMARY KEY ("id") 68 | ); 69 | 70 | -- CreateTable 71 | CREATE TABLE "Director" ( 72 | "id" SERIAL NOT NULL, 73 | "name" TEXT NOT NULL, 74 | "dob" TIMESTAMP(3) NOT NULL, 75 | "nationality" TEXT NOT NULL, 76 | 77 | CONSTRAINT "Director_pkey" PRIMARY KEY ("id") 78 | ); 79 | 80 | -- CreateTable 81 | CREATE TABLE "_ChatRoomToUser" ( 82 | "A" INTEGER NOT NULL, 83 | "B" INTEGER NOT NULL 84 | ); 85 | 86 | -- CreateTable 87 | CREATE TABLE "_GenreToMovie" ( 88 | "A" INTEGER NOT NULL, 89 | "B" INTEGER NOT NULL 90 | ); 91 | 92 | -- CreateIndex 93 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 94 | 95 | -- CreateIndex 96 | CREATE UNIQUE INDEX "Movie_title_key" ON "Movie"("title"); 97 | 98 | -- CreateIndex 99 | CREATE UNIQUE INDEX "Movie_detailId_key" ON "Movie"("detailId"); 100 | 101 | -- CreateIndex 102 | CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name"); 103 | 104 | -- CreateIndex 105 | CREATE UNIQUE INDEX "_ChatRoomToUser_AB_unique" ON "_ChatRoomToUser"("A", "B"); 106 | 107 | -- CreateIndex 108 | CREATE INDEX "_ChatRoomToUser_B_index" ON "_ChatRoomToUser"("B"); 109 | 110 | -- CreateIndex 111 | CREATE UNIQUE INDEX "_GenreToMovie_AB_unique" ON "_GenreToMovie"("A", "B"); 112 | 113 | -- CreateIndex 114 | CREATE INDEX "_GenreToMovie_B_index" ON "_GenreToMovie"("B"); 115 | 116 | -- AddForeignKey 117 | ALTER TABLE "Movie" ADD CONSTRAINT "Movie_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 118 | 119 | -- AddForeignKey 120 | ALTER TABLE "Movie" ADD CONSTRAINT "Movie_detailId_fkey" FOREIGN KEY ("detailId") REFERENCES "MovieDetail"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 121 | 122 | -- AddForeignKey 123 | ALTER TABLE "Movie" ADD CONSTRAINT "Movie_directorId_fkey" FOREIGN KEY ("directorId") REFERENCES "Director"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 124 | 125 | -- AddForeignKey 126 | ALTER TABLE "MovieUserLike" ADD CONSTRAINT "MovieUserLike_movieId_fkey" FOREIGN KEY ("movieId") REFERENCES "Movie"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 127 | 128 | -- AddForeignKey 129 | ALTER TABLE "MovieUserLike" ADD CONSTRAINT "MovieUserLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 130 | 131 | -- AddForeignKey 132 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 133 | 134 | -- AddForeignKey 135 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_chatRoomId_fkey" FOREIGN KEY ("chatRoomId") REFERENCES "ChatRoom"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 136 | 137 | -- AddForeignKey 138 | ALTER TABLE "_ChatRoomToUser" ADD CONSTRAINT "_ChatRoomToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "ChatRoom"("id") ON DELETE CASCADE ON UPDATE CASCADE; 139 | 140 | -- AddForeignKey 141 | ALTER TABLE "_ChatRoomToUser" ADD CONSTRAINT "_ChatRoomToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 142 | 143 | -- AddForeignKey 144 | ALTER TABLE "_GenreToMovie" ADD CONSTRAINT "_GenreToMovie_A_fkey" FOREIGN KEY ("A") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE; 145 | 146 | -- AddForeignKey 147 | ALTER TABLE "_GenreToMovie" ADD CONSTRAINT "_GenreToMovie_B_fkey" FOREIGN KEY ("B") REFERENCES "Movie"("id") ON DELETE CASCADE ON UPDATE CASCADE; 148 | -------------------------------------------------------------------------------- /prisma/schema/migrations/20241002144638_movie_model_update/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `updatedAt` to the `Movie` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Movie" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, 10 | ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0; 11 | -------------------------------------------------------------------------------- /prisma/schema/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DB_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | previewFeatures = ["prismaSchemaFolder", "omitApi"] 9 | } 10 | 11 | enum Role { 12 | admin 13 | paidUser 14 | user 15 | } 16 | 17 | model User { 18 | id Int @id @default(autoincrement()) 19 | email String @unique 20 | password String 21 | role Role @default(user) 22 | createdMovies Movie[] 23 | likedMovies MovieUserLike[] 24 | chats Chat[] 25 | chatRooms ChatRoom[] 26 | } 27 | 28 | model Movie { 29 | id Int @id @default(autoincrement()) 30 | creator User @relation(fields: [creatorId], references: [id]) 31 | creatorId Int 32 | title String @unique 33 | genres Genre[] 34 | likeCount Int @default(0) 35 | dislikeCount Int @default(0) 36 | detail MovieDetail @relation(fields: [detailId], references: [id]) 37 | detailId Int @unique 38 | movieFilePath String 39 | director Director @relation(fields: [directorId], references: [id]) 40 | directorId Int 41 | likedUsers MovieUserLike[] 42 | createdAt DateTime @default(now()) 43 | updatedAt DateTime @updatedAt 44 | version Int @default(0) 45 | } 46 | 47 | model MovieUserLike { 48 | movie Movie @relation(fields: [movieId], references: [id]) 49 | movieId Int 50 | user User @relation(fields: [userId], references: [id]) 51 | userId Int 52 | isLike Boolean 53 | 54 | @@id([movieId, userId]) 55 | } 56 | 57 | model MovieDetail { 58 | id Int @id @default(autoincrement()) 59 | detail String 60 | movie Movie? 61 | } 62 | 63 | model Chat { 64 | id Int @id @default(autoincrement()) 65 | author User @relation(fields: [authorId], references: [id]) 66 | authorId Int 67 | message String 68 | chatRoom ChatRoom @relation(fields: [chatRoomId], references: [id]) 69 | chatRoomId Int 70 | } 71 | 72 | model ChatRoom { 73 | id Int @id @default(autoincrement()) 74 | users User[] 75 | chats Chat[] 76 | } 77 | 78 | model Genre { 79 | id Int @id @default(autoincrement()) 80 | name String @unique 81 | movies Movie[] 82 | } 83 | 84 | model Director { 85 | id Int @id @default(autoincrement()) 86 | name String 87 | dob DateTime 88 | nationality String 89 | movies Movie[] 90 | } -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; 2 | import { MovieModule } from './movie/movie.module'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { ConditionalModule, ConfigModule, ConfigService } from '@nestjs/config'; 5 | import * as Joi from 'joi'; 6 | import { Movie } from './movie/entity/movie.entity'; 7 | import { MovieDetail } from './movie/entity/movie-detail.entity'; 8 | import { DirectorModule } from './director/director.module'; 9 | import { Director } from './director/entity/director.entity'; 10 | import { GenreModule } from './genre/genre.module'; 11 | import { Genre } from './genre/entity/genre.entity'; 12 | import { AuthModule } from './auth/auth.module'; 13 | import { UserModule } from './user/user.module'; 14 | import { User } from './user/entity/user.entity'; 15 | import { envVariableKeys } from './common/const/env.const'; 16 | import { BearerTokenMiddleware } from './auth/middleware/bearer-token.middleware'; 17 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; 18 | import { AuthGuard } from './auth/guard/auth.guard'; 19 | import { RBACGuard } from './auth/guard/rbac.guard'; 20 | import { ResponseTimeInterceptor } from './common/interceptor/response-time.interceptor'; 21 | import { ForbiddenExceptionFilter } from './common/filter/forbidden.filter'; 22 | import { QueryFailedExceptionFilter } from './common/filter/query-failed.filter'; 23 | import { ServeStaticModule } from '@nestjs/serve-static'; 24 | import { join } from 'path'; 25 | import { MovieUserLike } from './movie/entity/movie-user-like.entity'; 26 | import { CacheModule } from '@nestjs/cache-manager'; 27 | import { ThrottleInterceptor } from './common/interceptor/throttle.interceptor'; 28 | import { ScheduleModule } from '@nestjs/schedule'; 29 | import { WinstonModule } from 'nest-winston'; 30 | import * as winston from 'winston'; 31 | import { Chat } from './chat/entity/chat.entity'; 32 | import { ChatRoom } from './chat/entity/chat-room.entity'; 33 | import { ChatModule } from './chat/chat.module'; 34 | import { WorkerModule } from './worker/worker.module'; 35 | import { MongooseModule } from '@nestjs/mongoose'; 36 | 37 | @Module({ 38 | imports: [ 39 | ConfigModule.forRoot({ 40 | isGlobal: true, 41 | envFilePath: process.env.NODE_ENV === 'test' ? 'test.env' : '.env', 42 | validationSchema: Joi.object({ 43 | ENV: Joi.string().valid('test', 'dev', 'prod').required(), 44 | DB_TYPE: Joi.string().valid('postgres').required(), 45 | DB_HOST: Joi.string().required(), 46 | DB_PORT: Joi.number().required(), 47 | DB_USERNAME: Joi.string().required(), 48 | DB_PASSWORD: Joi.string().required(), 49 | DB_DATABASE: Joi.string().required(), 50 | DB_URL: Joi.string().required(), 51 | HASH_ROUNDS: Joi.number().required(), 52 | ACCESS_TOKEN_SECRET: Joi.string().required(), 53 | REFRESH_TOKEN_SECRET: Joi.string().required(), 54 | AWS_SECRET_ACCESS_KEY: Joi.string().required(), 55 | AWS_ACCESS_KEY_ID: Joi.string().required(), 56 | AWS_REGION: Joi.string().required(), 57 | BUCKET_NAME: Joi.string().required(), 58 | }), 59 | }), 60 | MongooseModule.forRoot('mongodb+srv://test:test@nestjsmongo.xicpm.mongodb.net/?retryWrites=true&w=majority&appName=NestJSMongo'), 61 | TypeOrmModule.forRootAsync({ 62 | useFactory: (configService: ConfigService) => ({ 63 | url: configService.get(envVariableKeys.dbUrl), 64 | type: configService.get(envVariableKeys.dbType) as "postgres", 65 | // host: configService.get(envVariableKeys.dbHost), 66 | // port: configService.get(envVariableKeys.dbPort), 67 | // username: configService.get(envVariableKeys.dbUsername), 68 | // password: configService.get(envVariableKeys.dbPassword), 69 | // database: configService.get(envVariableKeys.dbDatabase), 70 | entities: [ 71 | Movie, 72 | MovieDetail, 73 | MovieUserLike, 74 | Director, 75 | Genre, 76 | User, 77 | Chat, 78 | ChatRoom, 79 | ], 80 | synchronize: configService.get(envVariableKeys.env) === 'prod' ? false : true, 81 | ...(configService.get(envVariableKeys.env) === 'prod' && { 82 | ssl: { 83 | rejectUnauthorized: false, 84 | } 85 | }) 86 | }), 87 | inject: [ConfigService], 88 | }), 89 | ServeStaticModule.forRoot({ 90 | rootPath: join(process.cwd(), 'public'), 91 | serveRoot: '/public/' 92 | }), 93 | CacheModule.register({ 94 | ttl: 0, 95 | isGlobal: true, 96 | }), 97 | ScheduleModule.forRoot(), 98 | WinstonModule.forRoot({ 99 | level: 'debug', 100 | transports: [ 101 | new winston.transports.Console({ 102 | format: winston.format.combine( 103 | winston.format.colorize({ 104 | all: true, 105 | }), 106 | winston.format.timestamp(), 107 | winston.format.printf(info => `${info.timestamp} [${info.context}] ${info.level} ${info.message}`) 108 | ), 109 | }), 110 | new winston.transports.File({ 111 | dirname: join(process.cwd(), 'logs'), 112 | filename: 'logs.log', 113 | format: winston.format.combine( 114 | // winston.format.colorize({ 115 | // all: true, 116 | // }), 117 | winston.format.timestamp(), 118 | winston.format.printf(info => `${info.timestamp} [${info.context}] ${info.level} ${info.message}`) 119 | ), 120 | }) 121 | ], 122 | }), 123 | MovieModule, 124 | DirectorModule, 125 | GenreModule, 126 | AuthModule, 127 | UserModule, 128 | ChatModule, 129 | // ConditionalModule.registerWhen( 130 | // WorkerModule, 131 | // (env: NodeJS.ProcessEnv) => env['TYPE'] === 'worker', 132 | // ), 133 | ], 134 | providers: [ 135 | { 136 | provide: APP_GUARD, 137 | useClass: AuthGuard, 138 | }, 139 | { 140 | provide: APP_GUARD, 141 | useClass: RBACGuard, 142 | }, 143 | { 144 | provide: APP_INTERCEPTOR, 145 | useClass: ResponseTimeInterceptor, 146 | }, 147 | // { 148 | // provide: APP_FILTER, 149 | // useClass: ForbiddenExceptionFilter, 150 | // }, 151 | { 152 | provide: APP_FILTER, 153 | useClass: QueryFailedExceptionFilter, 154 | }, 155 | { 156 | provide: APP_INTERCEPTOR, 157 | useClass: ThrottleInterceptor, 158 | } 159 | ] 160 | }) 161 | export class AppModule implements NestModule { 162 | configure(consumer: MiddlewareConsumer) { 163 | consumer.apply( 164 | BearerTokenMiddleware, 165 | ).exclude({ 166 | path: 'auth/login', 167 | method: RequestMethod.POST, 168 | }, { 169 | path: 'auth/register', 170 | method: RequestMethod.POST, 171 | }) 172 | .forRoutes('*') 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { User } from 'src/user/entity/user.entity'; 5 | 6 | const mockAuthService = { 7 | register: jest.fn(), 8 | login: jest.fn(), 9 | tokenBlock: jest.fn(), 10 | issueToken: jest.fn(), 11 | } 12 | 13 | describe('AuthController', () => { 14 | let authController: AuthController; 15 | let authService: AuthService; 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | controllers: [AuthController], 20 | providers: [ 21 | { 22 | provide: AuthService, 23 | useValue: mockAuthService, 24 | } 25 | ], 26 | }).compile(); 27 | 28 | authController = module.get(AuthController); 29 | authService = module.get(AuthService); 30 | }); 31 | 32 | afterEach(() => { 33 | jest.clearAllMocks(); 34 | }) 35 | 36 | it('should be defined', () => { 37 | expect(authController).toBeDefined(); 38 | }); 39 | 40 | describe('registerUser', () => { 41 | it('should register a user', () => { 42 | const token = 'Basic dsivjoxicjvsdf'; 43 | const result = { id: 1, email: 'test@codefactory.ai' }; 44 | 45 | jest.spyOn(authService, 'register').mockResolvedValue(result as User); 46 | 47 | expect(authController.registerUser(token)).resolves.toEqual(result); 48 | expect(authService.register).toHaveBeenCalledWith(token); 49 | }) 50 | }); 51 | 52 | describe('loginUser', () => { 53 | it('should loign a user', async () => { 54 | const token = 'Basic asdivjoxicjv'; 55 | const result = { 56 | refreshToken: 'mocked.refresh.token', 57 | accessToken: 'mocked.access.token', 58 | }; 59 | 60 | jest.spyOn(authService, 'login').mockResolvedValue(result); 61 | 62 | expect(authController.loginUser(token)).resolves.toEqual(result); 63 | expect(authService.login).toHaveBeenCalledWith(token); 64 | }) 65 | }); 66 | 67 | describe('blockToken', () => { 68 | it('should block a token', async () => { 69 | const token = 'some.jwt.token'; 70 | jest.spyOn(authService, 'tokenBlock').mockResolvedValue(true); 71 | 72 | expect(authController.blockToken(token)).resolves.toBe(true); 73 | expect(authService.tokenBlock).toHaveBeenCalledWith(token); 74 | }) 75 | }); 76 | 77 | describe('rotateAccessToken', ()=>{ 78 | it('should rotate access token', async ()=>{ 79 | const accessToken = 'mocked.access.token'; 80 | 81 | jest.spyOn(authService, 'issueToken').mockResolvedValue(accessToken); 82 | 83 | const result = await authController.rotateAccessToken({user: 'a'}); 84 | 85 | expect(authService.issueToken).toHaveBeenCalledWith('a', false); 86 | expect(result).toEqual({accessToken}); 87 | }) 88 | }); 89 | 90 | describe('loginUserPassport', ()=>{ 91 | it('should login user using passport strategy', async ()=> { 92 | const user = {id: 1, role: 'user'}; 93 | const req = {user}; 94 | const accessToken = 'mocked.access.token'; 95 | const refreshToken = 'mocked.refresh.token'; 96 | 97 | jest.spyOn(authService, 'issueToken') 98 | .mockResolvedValueOnce(refreshToken) 99 | .mockResolvedValueOnce(accessToken); 100 | 101 | const result = await authController.loginUserPassport(req); 102 | 103 | expect(authService.issueToken).toHaveBeenCalledTimes(2); 104 | expect(authService.issueToken).toHaveBeenNthCalledWith(1, user, true); 105 | expect(authService.issueToken).toHaveBeenNthCalledWith(2, user, false); 106 | expect(result).toEqual({refreshToken, accessToken}); 107 | }) 108 | }) 109 | }); 110 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Headers, Request, UseGuards, Get, Body } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { LocalAuthGuard } from './strategy/local.strategy'; 5 | import { JwtAuthGuard } from './strategy/jwt.strategy'; 6 | import { Public } from './decorator/public.decorator'; 7 | import { ApiBasicAuth, ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 8 | import { Authorization } from './decorator/authorization.decorator'; 9 | 10 | @Controller('auth') 11 | @ApiBearerAuth() 12 | @ApiTags('auth') 13 | export class AuthController { 14 | constructor(private readonly authService: AuthService) { } 15 | 16 | @Public() 17 | @ApiBasicAuth() 18 | @Post('register') 19 | registerUser(@Authorization() token: string) { 20 | return this.authService.register(token); 21 | } 22 | 23 | @Public() 24 | @ApiBasicAuth() 25 | @Post('login') 26 | loginUser(@Authorization() token: string) { 27 | return this.authService.login(token); 28 | } 29 | 30 | @Post('token/block') 31 | blockToken( 32 | @Body('token') token: string, 33 | ){ 34 | return this.authService.tokenBlock(token); 35 | } 36 | 37 | @Post('token/access') 38 | async rotateAccessToken(@Request() req){ 39 | return { 40 | accessToken: await this.authService.issueToken(req.user, false), 41 | } 42 | } 43 | 44 | @UseGuards(LocalAuthGuard) 45 | @Post('login/passport') 46 | async loginUserPassport(@Request() req) { 47 | return { 48 | refreshToken: await this.authService.issueToken(req.user, true), 49 | accessToken: await this.authService.issueToken(req.user, false), 50 | }; 51 | } 52 | 53 | // @UseGuards(JwtAuthGuard) 54 | // @Get('private') 55 | // async private(@Request() req){ 56 | // return req.user; 57 | // } 58 | } 59 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | // import { User } from 'src/user/entity/user.entity'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { LocalStrategy } from './strategy/local.strategy'; 8 | import { JwtStrategy } from './strategy/jwt.strategy'; 9 | import { UserModule } from 'src/user/user.module'; 10 | import { CommonModule } from 'src/common/common.module'; 11 | import { MongooseModule } from '@nestjs/mongoose'; 12 | import { User, UserSchema } from 'src/user/schema/user.schema'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([ 17 | User, 18 | ]), 19 | MongooseModule.forFeature([ 20 | { 21 | name: User.name, 22 | schema: UserSchema, 23 | } 24 | ]), 25 | JwtModule.register({}), 26 | UserModule, 27 | CommonModule, 28 | ], 29 | controllers: [AuthController], 30 | providers: [AuthService, LocalStrategy, JwtStrategy], 31 | exports: [AuthService, JwtModule] 32 | }) 33 | export class AuthModule { } 34 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | import { Repository } from 'typeorm'; 4 | import { Role, User } from 'src/user/entity/user.entity'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | import { UserService } from 'src/user/user.service'; 8 | import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager'; 9 | import { getRepositoryToken } from '@nestjs/typeorm'; 10 | import { BadRequestException, UnauthorizedException } from '@nestjs/common'; 11 | import * as bcrypt from 'bcrypt'; 12 | 13 | const mockUserRepository = { 14 | findOne: jest.fn(), 15 | }; 16 | 17 | const mockConfigService = { 18 | get: jest.fn(), 19 | }; 20 | 21 | const mockJwtService = { 22 | signAsync: jest.fn(), 23 | verifyAsync: jest.fn(), 24 | decode: jest.fn(), 25 | }; 26 | 27 | const mockCacheManager = { 28 | set: jest.fn(), 29 | }; 30 | 31 | const mockUserService = { 32 | create: jest.fn(), 33 | }; 34 | 35 | describe('AuthService', () => { 36 | let authService: AuthService; 37 | let userRepository: Repository; 38 | let configService: ConfigService; 39 | let jwtService: JwtService; 40 | let cacheManager: Cache; 41 | let userService: UserService; 42 | 43 | beforeEach(async () => { 44 | const module: TestingModule = await Test.createTestingModule({ 45 | providers: [ 46 | AuthService, 47 | { 48 | provide: getRepositoryToken(User), 49 | useValue: mockUserRepository, 50 | }, 51 | { 52 | provide: ConfigService, 53 | useValue: mockConfigService, 54 | }, 55 | { 56 | provide: JwtService, 57 | useValue: mockJwtService, 58 | }, 59 | { 60 | provide: CACHE_MANAGER, 61 | useValue: mockCacheManager, 62 | }, 63 | { 64 | provide: UserService, 65 | useValue: mockUserService, 66 | } 67 | ], 68 | }).compile(); 69 | 70 | authService = module.get(AuthService); 71 | userRepository = module.get>(getRepositoryToken(User)); 72 | configService = module.get(ConfigService); 73 | jwtService = module.get(JwtService); 74 | cacheManager = module.get(CACHE_MANAGER); 75 | userService = module.get(UserService); 76 | }); 77 | 78 | afterEach(()=>{ 79 | jest.clearAllMocks(); 80 | }) 81 | 82 | it('should be defined', () => { 83 | expect(authService).toBeDefined(); 84 | }); 85 | 86 | describe('tokenBlock', ()=>{ 87 | it('should block a token', async ()=>{ 88 | const token = 'token'; 89 | const payload = { 90 | exp: (Math.floor(Date.now() / 1000)) + 60 91 | }; 92 | 93 | jest.spyOn(jwtService, 'decode').mockReturnValue(payload); 94 | 95 | await authService.tokenBlock(token); 96 | 97 | expect(jwtService.decode).toHaveBeenCalledWith(token); 98 | expect(cacheManager.set).toHaveBeenCalledWith( 99 | `BLOCK_TOKEN_${token}`, 100 | payload, 101 | expect.any(Number), 102 | ); 103 | }) 104 | }); 105 | 106 | describe('parseBasicToken', ()=>{ 107 | it('should parse a valid Basic Token', ()=>{ 108 | const rawToken = 'Basic dGVzdEBleGFtcGxlLmNvbToxMjM0NTY='; 109 | const result = authService.parseBasicToken(rawToken); 110 | 111 | const decode = {email: 'test@example.com', password: '123456'}; 112 | 113 | expect(result).toEqual(decode); 114 | }); 115 | 116 | it('should throw an error for invalid token format', ()=>{ 117 | const rawToken = 'InvalidTokenFormat'; 118 | expect(()=> authService.parseBasicToken(rawToken)).toThrow(BadRequestException); 119 | }); 120 | 121 | it('should throw an error for invalid Basic token format', ()=>{ 122 | const rawToken = 'Bearer InvalidTokenFormat'; 123 | expect(()=> authService.parseBasicToken(rawToken)).toThrow(BadRequestException); 124 | }); 125 | 126 | it('should throw an error for invalid Basic token format', ()=>{ 127 | const rawToken = 'basic a'; 128 | expect(()=> authService.parseBasicToken(rawToken)).toThrow(BadRequestException); 129 | }); 130 | }); 131 | 132 | describe('parseBearerToken', ()=>{ 133 | it('should parse a valid Bearer Token', async ()=>{ 134 | const rawToken = 'Bearer token'; 135 | const payload = {type: 'access'}; 136 | 137 | jest.spyOn(jwtService, 'verifyAsync').mockResolvedValue(payload); 138 | jest.spyOn(mockConfigService, 'get').mockReturnValue('secret'); 139 | 140 | const result = await authService.parseBearerToken(rawToken, false); 141 | 142 | expect(jwtService.verifyAsync).toHaveBeenCalledWith('token', { 143 | secret: 'secret' 144 | }); 145 | expect(result).toEqual(payload); 146 | }); 147 | 148 | it('should throw a BadRequestException for invalid Bearer token format', ()=>{ 149 | const rawToken = 'a'; 150 | expect(authService.parseBearerToken(rawToken, false)).rejects.toThrow(BadRequestException); 151 | }); 152 | 153 | it('should throw a BadRequestException for token not starting with Bearer', ()=>{ 154 | const rawToken = 'Basic a'; 155 | expect(authService.parseBearerToken(rawToken, false)).rejects.toThrow(BadRequestException); 156 | }); 157 | 158 | it('should throw a BadRequestException if payload.type is not refresh but isRefreshToken parameter is true', ()=>{ 159 | const rawToken = 'Bearer a'; 160 | 161 | jest.spyOn(jwtService, 'verifyAsync').mockResolvedValue({ 162 | type: 'refresh', 163 | }); 164 | 165 | expect(authService.parseBearerToken(rawToken, false)).rejects.toThrow(UnauthorizedException); 166 | }); 167 | 168 | it('should throw a BadRequestException if payload.type is not refresh but isRefreshToken parameter is true', ()=>{ 169 | const rawToken = 'Bearer a'; 170 | 171 | jest.spyOn(jwtService, 'verifyAsync').mockResolvedValue({ 172 | type: 'access', 173 | }); 174 | 175 | expect(authService.parseBearerToken(rawToken, true)).rejects.toThrow(UnauthorizedException); 176 | }); 177 | }); 178 | 179 | describe('register', ()=>{ 180 | it('should register a new user', async ()=>{ 181 | const rawToken = 'basic abcd'; 182 | const user = { 183 | email: 'test@codefactory.ai', 184 | password: '123123', 185 | } 186 | 187 | jest.spyOn(authService, 'parseBasicToken').mockReturnValue(user); 188 | jest.spyOn(mockUserService, 'create').mockResolvedValue(user); 189 | 190 | const result = await authService.register(rawToken); 191 | 192 | expect(authService.parseBasicToken).toHaveBeenCalledWith(rawToken); 193 | expect(userService.create).toHaveBeenCalledWith(user); 194 | expect(result).toEqual(user); 195 | }) 196 | }); 197 | 198 | describe('authenticate', ()=>{ 199 | it('should autehtnicate a user with correct credentials', async ()=>{ 200 | const email = 'test@codefactory.ai'; 201 | const password = '123123'; 202 | const user = {email, password: 'hashedpassword'}; 203 | 204 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(user); 205 | jest.spyOn(bcrypt, 'compare').mockImplementation((a,b)=> true); 206 | 207 | const result = await authService.authenticate(email, password); 208 | 209 | expect(userRepository.findOne).toHaveBeenCalledWith({where: {email}}); 210 | expect(bcrypt.compare).toHaveBeenCalledWith(password, 'hashedpassword'); 211 | expect(result).toEqual(user); 212 | }); 213 | 214 | it('should throw an error for not existing user', async ()=>{ 215 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(null); 216 | 217 | expect(authService.authenticate('test@example.com', 'password')).rejects.toThrow(BadRequestException); 218 | }); 219 | 220 | it('should throw an error for incorrect password', async ()=>{ 221 | const user = {email: 'test@codefactory.ai', password: 'hashedPassword'}; 222 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(user); 223 | jest.spyOn(bcrypt, 'compare').mockImplementation((a,b)=> false); 224 | 225 | await expect(authService.authenticate('test@codefactory.ai', 'password')).rejects.toThrow( 226 | BadRequestException, 227 | ); 228 | }) 229 | }); 230 | 231 | describe('issueToken', ()=>{ 232 | const user = {id: 1, role: Role.user}; 233 | const token = 'token'; 234 | 235 | beforeEach(()=>{ 236 | jest.spyOn(mockConfigService, 'get').mockReturnValue('secret'); 237 | jest.spyOn(jwtService, 'signAsync').mockResolvedValue(token); 238 | }) 239 | 240 | it('should issue an access token', async ()=>{ 241 | const result = await authService.issueToken(user as User, false); 242 | 243 | expect(jwtService.signAsync).toHaveBeenCalledWith( 244 | {sub: user.id, type: 'access', role: user.role}, 245 | {secret: 'secret', expiresIn: 300}, 246 | ); 247 | expect(result).toBe(token); 248 | }) 249 | 250 | it('should issue an access token', async ()=>{ 251 | const result = await authService.issueToken(user as User, true); 252 | 253 | expect(jwtService.signAsync).toHaveBeenCalledWith( 254 | {sub: user.id, type: 'refresh', role: user.role}, 255 | {secret: 'secret', expiresIn: '24h'}, 256 | ); 257 | expect(result).toBe(token); 258 | }) 259 | }); 260 | 261 | describe('login', ()=>{ 262 | it('should login a user and return tokens', async ()=>{ 263 | const rawToken = 'Basic asdf'; 264 | const email = 'test@codefactory.ai'; 265 | const password = '123123'; 266 | const user = {id: 1, role: Role.user}; 267 | 268 | jest.spyOn(authService, 'parseBasicToken').mockReturnValue({email, password}); 269 | jest.spyOn(authService, 'authenticate').mockResolvedValue(user as User); 270 | jest.spyOn(authService, 'issueToken').mockResolvedValue('mocked.token'); 271 | 272 | const result = await authService.login(rawToken); 273 | 274 | expect(authService.parseBasicToken).toHaveBeenCalledWith(rawToken); 275 | expect(authService.authenticate).toHaveBeenCalledWith(email, password); 276 | expect(authService.issueToken).toHaveBeenCalledTimes(2); 277 | expect(result).toEqual({ 278 | refreshToken: 'mocked.token', 279 | accessToken: 'mocked.token', 280 | }) 281 | }) 282 | }) 283 | }); 284 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import * as bcrypt from 'bcrypt'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | import { envVariableKeys } from 'src/common/const/env.const'; 8 | import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager'; 9 | import { UserService } from 'src/user/user.service'; 10 | import { PrismaClient, Role } from '@prisma/client'; 11 | import { PrismaService } from 'src/common/prisma.service'; 12 | import { Model } from 'mongoose'; 13 | import { InjectModel } from '@nestjs/mongoose'; 14 | import { User } from 'src/user/schema/user.schema'; 15 | 16 | @Injectable() 17 | export class AuthService { 18 | constructor( 19 | // @InjectRepository(User) 20 | // private readonly userRepository: Repository, 21 | private readonly userService: UserService, 22 | private readonly configService: ConfigService, 23 | private readonly jwtService: JwtService, 24 | @Inject(CACHE_MANAGER) 25 | private readonly cacheManager: Cache, 26 | // private readonly prisma: PrismaService, 27 | @InjectModel(User.name) 28 | private readonly userModel: Model 29 | ) { } 30 | 31 | async tokenBlock(token: string) { 32 | const payload = this.jwtService.decode(token); 33 | 34 | const expiryDate = +new Date(payload['exp'] * 1000); 35 | const now = +Date.now(); 36 | 37 | const differenceInSeconds = (expiryDate - now) / 1000; 38 | 39 | await this.cacheManager.set(`BLOCK_TOKEN_${token}`, payload, 40 | Math.max(differenceInSeconds * 1000, 1) 41 | ) 42 | 43 | return true; 44 | } 45 | 46 | parseBasicToken(rawToken: string) { 47 | /// 1) 토큰을 ' ' 기준으로 스플릿 한 후 토큰 값만 추출하기 48 | /// ['Basic', $token] 49 | const basicSplit = rawToken.split(' '); 50 | 51 | if (basicSplit.length !== 2) { 52 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 53 | } 54 | 55 | const [basic, token] = basicSplit; 56 | 57 | if (basic.toLowerCase() !== 'basic') { 58 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 59 | } 60 | 61 | /// 2) 추출한 토큰을 base64 디코딩해서 이메일과 비밀번호로 나눈다. 62 | const decoded = Buffer.from(token, 'base64').toString('utf-8'); 63 | 64 | /// "email:password" 65 | /// [email, password] 66 | const tokenSplit = decoded.split(':'); 67 | 68 | if (tokenSplit.length !== 2) { 69 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!') 70 | } 71 | 72 | const [email, password] = tokenSplit; 73 | 74 | return { 75 | email, 76 | password, 77 | } 78 | } 79 | 80 | async parseBearerToken(rawToken: string, isRefreshToken: boolean) { 81 | const basicSplit = rawToken.split(' '); 82 | 83 | if (basicSplit.length !== 2) { 84 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 85 | } 86 | 87 | const [bearer, token] = basicSplit; 88 | 89 | if (bearer.toLowerCase() !== 'bearer') { 90 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 91 | } 92 | 93 | try { 94 | const payload = await this.jwtService.verifyAsync(token, { 95 | secret: this.configService.get( 96 | isRefreshToken ? envVariableKeys.refreshTokenSecret : envVariableKeys.accessTokenSecret, 97 | ), 98 | }); 99 | 100 | if (isRefreshToken) { 101 | if (payload.type !== 'refresh') { 102 | throw new BadRequestException('Refresh 토큰을 입력 해주세요!'); 103 | } 104 | } else { 105 | if (payload.type !== 'access') { 106 | throw new BadRequestException('Access 토큰을 입력 해주세요!') 107 | } 108 | } 109 | 110 | return payload; 111 | } catch (e) { 112 | throw new UnauthorizedException('토큰이 만료됐습니다!'); 113 | } 114 | } 115 | 116 | /// rawToken -> "Basic $token" 117 | async register(rawToken: string) { 118 | const { email, password } = this.parseBasicToken(rawToken); 119 | 120 | return this.userService.create({ 121 | email, 122 | password, 123 | }); 124 | } 125 | 126 | async authenticate(email: string, password: string) { 127 | 128 | const user = await this.userModel.findOne({ 129 | email, 130 | }, { 131 | password: 1, 132 | role: 1, 133 | }); 134 | // const user = await this.prisma.user.findUnique({ 135 | // where: { 136 | // email, 137 | // }, 138 | // select: { 139 | // id: true, 140 | // password: true, 141 | // role: true, 142 | // } 143 | // }) 144 | // const user = await this.userRepository.findOne({ 145 | // where: { 146 | // email, 147 | // }, 148 | // }); 149 | 150 | if (!user) { 151 | throw new BadRequestException('잘못된 로그인 정보입니다!'); 152 | } 153 | 154 | const passOk = await bcrypt.compare(password, user.password); 155 | 156 | if (!passOk) { 157 | throw new BadRequestException('잘못된 로그인 정보입니다!'); 158 | } 159 | 160 | return user; 161 | } 162 | 163 | async issueToken(user: { _id: any, role: Role }, isRefreshToken: boolean) { 164 | const refreshTokenSecret = this.configService.get(envVariableKeys.refreshTokenSecret); 165 | const accessTokenSecret = this.configService.get(envVariableKeys.accessTokenSecret); 166 | 167 | return this.jwtService.signAsync({ 168 | sub: user._id, 169 | role: user.role, 170 | type: isRefreshToken ? 'refresh' : 'access', 171 | }, { 172 | secret: isRefreshToken ? refreshTokenSecret : accessTokenSecret, 173 | expiresIn: isRefreshToken ? '24h' : 300, 174 | }) 175 | } 176 | 177 | async login(rawToken: string) { 178 | const { email, password } = this.parseBasicToken(rawToken); 179 | 180 | const user = await this.authenticate(email, password); 181 | 182 | return { 183 | refreshToken: await this.issueToken(user, true), 184 | accessToken: await this.issueToken(user, false), 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/auth/decorator/authorization.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from "@nestjs/common"; 2 | 3 | export const Authorization = createParamDecorator( 4 | (data: any, context: ExecutionContext) => { 5 | const req = context.switchToHttp().getRequest(); 6 | 7 | return req.headers['authorization']; 8 | } 9 | ); -------------------------------------------------------------------------------- /src/auth/decorator/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from "@nestjs/core"; 2 | 3 | export const Public = Reflector.createDecorator(); -------------------------------------------------------------------------------- /src/auth/decorator/rbac.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from "@nestjs/core"; 2 | import { Role } from "@prisma/client"; 3 | 4 | export const RBAC = Reflector.createDecorator(); -------------------------------------------------------------------------------- /src/auth/guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Reflector } from "@nestjs/core"; 3 | import { Observable } from "rxjs"; 4 | import { Public } from "../decorator/public.decorator"; 5 | 6 | @Injectable() 7 | export class AuthGuard implements CanActivate{ 8 | constructor( 9 | private readonly reflector: Reflector, 10 | ){} 11 | 12 | canActivate(context: ExecutionContext): boolean { 13 | // 만약에 public decoration이 돼있으면 14 | // 모든 로직을 bypass 15 | const isPublic = this.reflector.get(Public, context.getHandler()); 16 | 17 | if(isPublic){ 18 | return true; 19 | } 20 | 21 | // 요청에서 user 객체가 존재하는지 확인한다. 22 | const request = context.switchToHttp().getRequest(); 23 | 24 | if(!request.user || request.user.type !== 'access'){ 25 | return false; 26 | } 27 | 28 | return true; 29 | } 30 | } -------------------------------------------------------------------------------- /src/auth/guard/rbac.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Reflector } from "@nestjs/core"; 3 | import { Observable } from "rxjs"; 4 | import { RBAC } from "../decorator/rbac.decorator"; 5 | import { Role } from '@prisma/client'; 6 | 7 | @Injectable() 8 | export class RBACGuard implements CanActivate { 9 | constructor( 10 | private readonly reflector: Reflector, 11 | ) { } 12 | 13 | canActivate(context: ExecutionContext): boolean { 14 | const role = this.reflector.get(RBAC, context.getHandler()); 15 | 16 | /// Role Enum에 해당되는 값이 데코레이터에 들어갔는지 확인하기! 17 | if (!Object.values(Role).includes(role)) { 18 | return true; 19 | } 20 | 21 | const request = context.switchToHttp().getRequest(); 22 | 23 | const user = request.user; 24 | 25 | if (!user) { 26 | return false; 27 | } 28 | 29 | const roleAccessLevel = { 30 | [Role.admin]: 0, 31 | [Role.paidUser]: 1, 32 | [Role.user]: 2, 33 | } 34 | 35 | return roleAccessLevel[user.role] <= roleAccessLevel[role]; 36 | } 37 | } -------------------------------------------------------------------------------- /src/auth/middleware/bearer-token.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; 2 | import { BadRequestException, ForbiddenException, Inject, Injectable, NestMiddleware, UnauthorizedException } from "@nestjs/common"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import { JwtService } from "@nestjs/jwt"; 5 | import { NextFunction, Request, Response } from "express"; 6 | import { envVariableKeys } from "src/common/const/env.const"; 7 | 8 | @Injectable() 9 | export class BearerTokenMiddleware implements NestMiddleware { 10 | constructor( 11 | private readonly jwtService: JwtService, 12 | private readonly configService: ConfigService, 13 | @Inject(CACHE_MANAGER) 14 | private readonly cacheManager: Cache, 15 | ) { } 16 | 17 | async use(req: Request, res: Response, next: NextFunction) { 18 | /// Basic $token 19 | /// Bearer $token 20 | const authHeader = req.headers['authorization']; 21 | 22 | if (!authHeader) { 23 | next(); 24 | return; 25 | } 26 | 27 | const token = this.validateBearerToken(authHeader); 28 | 29 | const blockedToken = await this.cacheManager.get(`BLOCK_TOKEN_${token}`); 30 | 31 | if(blockedToken){ 32 | throw new UnauthorizedException('차단된 토큰입니다!'); 33 | } 34 | 35 | const tokenKey = `TOKEN_${token}`; 36 | 37 | const cachedPayload = await this.cacheManager.get(tokenKey); 38 | 39 | if (cachedPayload) { 40 | req.user = cachedPayload; 41 | 42 | return next(); 43 | } 44 | 45 | const decodedPayload = this.jwtService.decode(token); 46 | 47 | if (decodedPayload.type !== 'refresh' && decodedPayload.type !== 'access') { 48 | throw new UnauthorizedException('잘못된 토큰입니다!'); 49 | } 50 | 51 | try { 52 | 53 | const secretKey = decodedPayload.type === 'refresh' ? 54 | envVariableKeys.refreshTokenSecret : 55 | envVariableKeys.accessTokenSecret; 56 | 57 | const payload = await this.jwtService.verifyAsync(token, { 58 | secret: this.configService.get( 59 | secretKey, 60 | ), 61 | }); 62 | 63 | /// payload['exp'] -> epoch time seconds 64 | const expiryDate = +new Date(payload['exp'] * 1000); 65 | const now = +Date.now(); 66 | 67 | const differenceInSeconds = (expiryDate - now) / 1000; 68 | 69 | await this.cacheManager.set(tokenKey, payload, 70 | Math.max((differenceInSeconds - 30) * 1000, 1) 71 | ) 72 | 73 | req.user = payload; 74 | next(); 75 | } catch (e) { 76 | if (e.name === 'TokenExpiredError') { 77 | throw new UnauthorizedException('토큰이 만료됐습니다.'); 78 | } 79 | 80 | next(); 81 | } 82 | } 83 | 84 | validateBearerToken(rawToken: string) { 85 | const basicSplit = rawToken.split(' '); 86 | 87 | if (basicSplit.length !== 2) { 88 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 89 | } 90 | 91 | const [bearer, token] = basicSplit; 92 | 93 | if (bearer.toLowerCase() !== 'bearer') { 94 | throw new BadRequestException('토큰 포맷이 잘못됐습니다!'); 95 | } 96 | 97 | return token; 98 | } 99 | } -------------------------------------------------------------------------------- /src/auth/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { ConfigService } from "@nestjs/config"; 3 | import { AuthGuard, PassportStrategy } from "@nestjs/passport"; 4 | import { ExtractJwt, Strategy } from "passport-jwt"; 5 | 6 | export class JwtAuthGuard extends AuthGuard('jwt'){} 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | private readonly configService: ConfigService, 12 | ){ 13 | super({ 14 | /// Bearer $token 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: configService.get('ACCESS_TOKEN_SECRET'), 18 | }); 19 | } 20 | 21 | validate(payload: any){ 22 | return payload; 23 | } 24 | } -------------------------------------------------------------------------------- /src/auth/strategy/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { AuthGuard, PassportStrategy } from "@nestjs/passport"; 3 | import { Strategy } from "passport-local"; 4 | import { AuthService } from "../auth.service"; 5 | 6 | export class LocalAuthGuard extends AuthGuard('codefactory'){}; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy, 'codefactory'){ 10 | constructor( 11 | private readonly authService: AuthService, 12 | ){ 13 | super({ 14 | usernameField: 'email' 15 | }); 16 | } 17 | 18 | /** 19 | * LocalStrategy 20 | * 21 | * validate : username, password 22 | * 23 | * return -> Request(); 24 | */ 25 | async validate(email: string, password: string){ 26 | const user = await this.authService.authenticate(email, password); 27 | 28 | return user; 29 | } 30 | } -------------------------------------------------------------------------------- /src/chat/chat.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatGateway } from './chat.gateway'; 3 | import { ChatService } from './chat.service'; 4 | 5 | describe('ChatGateway', () => { 6 | let gateway: ChatGateway; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ChatGateway, ChatService], 11 | }).compile(); 12 | 13 | gateway = module.get(ChatGateway); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(gateway).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/chat/chat.gateway.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; 2 | import { ChatService } from './chat.service'; 3 | import { Socket } from 'socket.io'; 4 | import { AuthService } from 'src/auth/auth.service'; 5 | import { UseInterceptors } from '@nestjs/common'; 6 | import { WsTransactionInterceptor } from 'src/common/interceptor/ws-transaction.interceptor'; 7 | import { WsQueryRunner } from 'src/common/decorator/ws-query-runner.decorator'; 8 | import { QueryRunner } from 'typeorm'; 9 | import { CreateChatDto } from './dto/create-chat.dto'; 10 | 11 | @WebSocketGateway() 12 | export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { 13 | constructor( 14 | private readonly chatService: ChatService, 15 | private readonly authService: AuthService, 16 | ) { } 17 | 18 | handleDisconnect(client: Socket) { 19 | const user = client.data.user; 20 | 21 | if (user) { 22 | this.chatService.removeClient(user.sub); 23 | } 24 | } 25 | 26 | async handleConnection(client: Socket) { 27 | try { 28 | // Bearer 'skjcvoizxcjvlzxicv' 29 | const rawToken = client.handshake.headers.authorization; 30 | 31 | const payload = await this.authService.parseBearerToken(rawToken, false); 32 | 33 | if (payload) { 34 | client.data.user = payload; 35 | this.chatService.registerClient(payload.sub, client) 36 | await this.chatService.joinUserRooms(payload, client); 37 | } else { 38 | client.disconnect(); 39 | } 40 | } catch (e) { 41 | console.log(e); 42 | client.disconnect(); 43 | } 44 | } 45 | 46 | @SubscribeMessage('sendMessage') 47 | @UseInterceptors(WsTransactionInterceptor) 48 | async handleMessage( 49 | @MessageBody() body: CreateChatDto, 50 | @ConnectedSocket() client: Socket, 51 | @WsQueryRunner() qr: QueryRunner, 52 | ) { 53 | const payload = client.data.user; 54 | await this.chatService.createMessage(payload, body, qr); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChatService } from './chat.service'; 3 | import { ChatGateway } from './chat.gateway'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { User } from 'src/user/entity/user.entity'; 7 | import { Chat } from './entity/chat.entity'; 8 | import { ChatRoom } from './entity/chat-room.entity'; 9 | 10 | @Module({ 11 | imports: [AuthModule, TypeOrmModule.forFeature([ 12 | User, 13 | Chat, 14 | ChatRoom, 15 | ])], 16 | providers: [ChatGateway, ChatService], 17 | }) 18 | export class ChatModule {} 19 | -------------------------------------------------------------------------------- /src/chat/chat.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatService } from './chat.service'; 3 | 4 | describe('ChatService', () => { 5 | let service: ChatService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ChatService], 10 | }).compile(); 11 | 12 | service = module.get(ChatService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/chat/chat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Socket } from 'socket.io'; 4 | import { ChatRoom } from './entity/chat-room.entity'; 5 | import { QueryRunner, Repository } from 'typeorm'; 6 | import { Chat } from './entity/chat.entity'; 7 | import { Role, User } from 'src/user/entity/user.entity'; 8 | import { CreateChatDto } from './dto/create-chat.dto'; 9 | import { WsException } from '@nestjs/websockets'; 10 | import { plainToClass } from 'class-transformer'; 11 | 12 | @Injectable() 13 | export class ChatService { 14 | private readonly connectedClients = new Map(); 15 | 16 | constructor( 17 | @InjectRepository(ChatRoom) 18 | private readonly chatRoomRepository: Repository, 19 | @InjectRepository(Chat) 20 | private readonly chatRepository: Repository, 21 | @InjectRepository(User) 22 | private readonly userRepository: Repository, 23 | ) { 24 | 25 | } 26 | 27 | registerClient(userId: number, client: Socket) { 28 | this.connectedClients.set(userId, client); 29 | } 30 | 31 | removeClient(userId: number) { 32 | this.connectedClients.delete(userId); 33 | } 34 | 35 | async joinUserRooms(user: { sub: number }, client: Socket) { 36 | const chatRooms = await this.chatRoomRepository.createQueryBuilder('chatRoom') 37 | .innerJoin('chatRoom.users', 'user', 'user.id = :userId', { 38 | userId: user.sub, 39 | }) 40 | .getMany(); 41 | 42 | chatRooms.forEach((room) => { 43 | client.join(room.id.toString()); 44 | }); 45 | } 46 | 47 | async createMessage(payload: { sub: number }, { message, room }: CreateChatDto, qr: QueryRunner) { 48 | const user = await this.userRepository.findOne({ 49 | where: { 50 | id: payload.sub, 51 | }, 52 | }); 53 | 54 | const chatRoom = await this.getOrCreateChatRoom(user, qr, room); 55 | 56 | const msgModel = await qr.manager.save(Chat, { 57 | author: user, 58 | message, 59 | chatRoom, 60 | }); 61 | 62 | const client = this.connectedClients.get(user.id); 63 | 64 | client.to(chatRoom.id.toString()).emit('newMessage', plainToClass(Chat, msgModel)); 65 | 66 | return message; 67 | } 68 | 69 | async getOrCreateChatRoom(user: User, qr: QueryRunner, room?: number) { 70 | if (user.role === Role.admin) { 71 | if (!room) { 72 | throw new WsException('어드민은 room 값을 필수로 제공해야합니다.'); 73 | } 74 | 75 | return qr.manager.findOne(ChatRoom, { 76 | where: { id: room }, 77 | relations: ['users'], 78 | }); 79 | } 80 | 81 | let chatRoom = await qr.manager.createQueryBuilder(ChatRoom, 'chatRoom') 82 | .innerJoin('chatRoom.users', 'user') 83 | .where('user.id = :userId', { userId: user.id }) 84 | .getOne(); 85 | 86 | if (!chatRoom) { 87 | const adminUser = await qr.manager.findOne(User, { where: { role: Role.admin } }); 88 | 89 | chatRoom = await this.chatRoomRepository.save({ 90 | users: [user, adminUser], 91 | }); 92 | 93 | [user.id, adminUser.id].forEach((userId) => { 94 | const client = this.connectedClients.get(userId); 95 | 96 | if (client) { 97 | client.emit('roomCreated', chatRoom.id); 98 | client.join(chatRoom.id.toString()); 99 | } 100 | }) 101 | } 102 | 103 | return chatRoom; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/chat/dto/create-chat.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from "class-validator"; 2 | 3 | export class CreateChatDto{ 4 | @IsString() 5 | message: string; 6 | 7 | @IsNumber() 8 | @IsOptional() 9 | room?: number; 10 | } -------------------------------------------------------------------------------- /src/chat/entity/chat-room.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseTable } from "src/common/entity/base-table.entity"; 2 | import { User } from "src/user/entity/user.entity"; 3 | import { Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; 4 | import { Chat } from "./chat.entity"; 5 | 6 | @Entity() 7 | export class ChatRoom extends BaseTable { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @ManyToMany( 12 | () => User, 13 | (user) => user.chatRooms 14 | ) 15 | @JoinTable() 16 | users: User[]; 17 | 18 | @OneToMany( 19 | () => Chat, 20 | (chat) => chat.chatRoom, 21 | ) 22 | chats: Chat[]; 23 | } -------------------------------------------------------------------------------- /src/chat/entity/chat.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; 2 | import { User } from "src/user/entity/user.entity"; 3 | import { BaseTable } from "src/common/entity/base-table.entity"; 4 | import { ChatRoom } from "./chat-room.entity"; 5 | 6 | @Entity() 7 | export class Chat extends BaseTable { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @ManyToOne( 12 | () => User, 13 | (user) => user.chats 14 | ) 15 | author: User; 16 | 17 | @Column() 18 | message: string; 19 | 20 | @ManyToOne( 21 | ()=> ChatRoom, 22 | (chatRoom) => chatRoom.chats 23 | ) 24 | chatRoom: ChatRoom; 25 | } -------------------------------------------------------------------------------- /src/chat/schema/chat-room.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Movie } from "src/movie/schema/movie.schema"; 3 | import { Document, Types } from 'mongoose'; 4 | import { User } from 'src/user/schema/user.schema'; 5 | import { Chat } from "./chat.schema"; 6 | 7 | @Schema({ 8 | timestamps: true, 9 | }) 10 | export class ChatRoom extends Document { 11 | @Prop({ 12 | type: [{ 13 | type: Types.ObjectId, 14 | ref: 'User' 15 | }] 16 | }) 17 | users: User[]; 18 | 19 | @Prop({ 20 | type: [{ 21 | type: Types.ObjectId, 22 | ref: 'Chat', 23 | }] 24 | }) 25 | chats: Chat[]; 26 | } 27 | 28 | export const ChatRoomSchema = SchemaFactory.createForClass(ChatRoom); -------------------------------------------------------------------------------- /src/chat/schema/chat.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Movie } from "src/movie/schema/movie.schema"; 3 | import { Document, Types } from 'mongoose'; 4 | import {User} from 'src/user/schema/user.schema'; 5 | import { ChatRoom } from "./chat-room.schema"; 6 | 7 | @Schema({ 8 | timestamps:true, 9 | }) 10 | export class Chat extends Document{ 11 | @Prop({ 12 | type: Types.ObjectId, 13 | ref: 'User', 14 | required: true, 15 | }) 16 | author: User; 17 | 18 | @Prop({ 19 | required: true, 20 | }) 21 | message: string; 22 | 23 | @Prop({ 24 | type: Types.ObjectId, 25 | ref: 'ChatRoom', 26 | required: true, 27 | }) 28 | chatRoom: ChatRoom; 29 | } 30 | 31 | export const ChatSchema = SchemaFactory.createForClass(Chat); -------------------------------------------------------------------------------- /src/common/common.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommonController } from './common.controller'; 3 | import { CommonService } from './common.service'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | 6 | describe('CommonController', () => { 7 | let controller: CommonController; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | imports: [ConfigModule.forRoot()], 12 | controllers: [CommonController], 13 | providers: [CommonService] 14 | }).compile(); 15 | 16 | controller = module.get(CommonController); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/common/common.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; 2 | import { FileInterceptor } from '@nestjs/platform-express'; 3 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 4 | import { CommonService } from './common.service'; 5 | import { Queue } from 'bullmq'; 6 | import { InjectQueue } from '@nestjs/bull'; 7 | 8 | @Controller('common') 9 | @ApiBearerAuth() 10 | @ApiTags('common') 11 | export class CommonController { 12 | constructor( 13 | private readonly commonService: CommonService, 14 | // @InjectQueue('thumbnail-generation') 15 | // private readonly thumbnailQueue: Queue, 16 | ) { 17 | 18 | } 19 | 20 | @Post('video') 21 | @UseInterceptors(FileInterceptor('video', { 22 | limits: { 23 | fileSize: 20000000, 24 | }, 25 | fileFilter(req, file, callback) { 26 | if (file.mimetype !== 'video/mp4') { 27 | return callback( 28 | new BadRequestException('MP4 타입만 업로드 가능합니다!'), 29 | false 30 | ) 31 | } 32 | 33 | return callback(null, true); 34 | } 35 | })) 36 | async createVideo( 37 | @UploadedFile() movie: Express.Multer.File, 38 | ) { 39 | // await this.thumbnailQueue.add('thumbnail', { 40 | // videoId: movie.filename, 41 | // videoPath: movie.path, 42 | // }, { 43 | // priority: 1, 44 | // delay: 100, 45 | // attempts: 3, 46 | // lifo: true, 47 | // removeOnComplete: true, 48 | // removeOnFail: true, 49 | // }); 50 | 51 | return { 52 | fileName: movie.filename, 53 | } 54 | } 55 | 56 | @Post('presigned-url') 57 | async createPresignedUrl() { 58 | return { 59 | url: await this.commonService.createPresignedUrl(), 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CommonService } from "./common.service"; 3 | import { CommonController } from './common.controller'; 4 | import { MulterModule } from "@nestjs/platform-express"; 5 | import { diskStorage } from "multer"; 6 | import { join } from 'path'; 7 | import { v4 } from 'uuid'; 8 | import { TasksService } from "./tasks.service"; 9 | import { TypeOrmModule } from "@nestjs/typeorm"; 10 | import { Movie } from "src/movie/entity/movie.entity"; 11 | import { DefaultLogger } from "./logger/default.logger"; 12 | import { BullModule } from "@nestjs/bullmq"; 13 | import { PrismaService } from "./prisma.service"; 14 | 15 | @Module({ 16 | imports: [ 17 | MulterModule.register({ 18 | storage: diskStorage({ 19 | /// ......./Netflix/public/movie 20 | /// process.cwd() + '/public' + '/movie' 21 | /// process.cwd() + '\public' + '\movie' 22 | destination: join(process.cwd(), 'public', 'temp'), 23 | filename: (req, file, cb) => { 24 | const split = file.originalname.split('.'); 25 | 26 | let extension = 'mp4'; 27 | 28 | if (split.length > 1) { 29 | extension = split[split.length - 1]; 30 | } 31 | 32 | cb(null, `${v4()}_${Date.now()}.${extension}`); 33 | } 34 | }), 35 | }), 36 | TypeOrmModule.forFeature([ 37 | Movie, 38 | ]), 39 | // BullModule.forRoot({ 40 | // connection: { 41 | // host: 'redis-14693.c340.ap-northeast-2-1.ec2.redns.redis-cloud.com', 42 | // port: 14693, 43 | // username: 'default', 44 | // password: 'ubZ1DR3UoLj8v7bjidGEOOzX10I8sxUw', 45 | // } 46 | // }), 47 | // BullModule.registerQueue({ 48 | // name: 'thumbnail-generation', 49 | // }), 50 | ], 51 | controllers: [CommonController], 52 | providers: [CommonService, DefaultLogger, PrismaService], 53 | exports: [CommonService, PrismaService], 54 | }) 55 | export class CommonModule { } -------------------------------------------------------------------------------- /src/common/common.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common"; 2 | import { SelectQueryBuilder } from "typeorm"; 3 | import { PagePaginationDto } from "./dto/page-pagination.dto"; 4 | import { CursorPaginationDto } from "./dto/cursor-pagination.dto"; 5 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 6 | import { ObjectCannedACL, PutObjectCommand, S3 } from '@aws-sdk/client-s3'; 7 | import { v4 as Uuid } from 'uuid'; 8 | import { ConfigService } from "@nestjs/config"; 9 | import { envVariableKeys } from "./const/env.const"; 10 | 11 | @Injectable() 12 | export class CommonService { 13 | private s3: S3; 14 | 15 | constructor( 16 | private readonly configService: ConfigService 17 | ) { 18 | this.s3 = new S3({ 19 | credentials: { 20 | accessKeyId: configService.get(envVariableKeys.awsAccessKeyId), 21 | secretAccessKey: configService.get(envVariableKeys.awsSecretAccessKey), 22 | }, 23 | 24 | region: configService.get(envVariableKeys.awsRegion), 25 | }); 26 | } 27 | 28 | async saveMovieToPermanentStorage(fileName: string) { 29 | try { 30 | const bucketName = this.configService.get(envVariableKeys.bucketName); 31 | await this.s3.copyObject({ 32 | Bucket: bucketName, 33 | CopySource: `${bucketName}/public/temp/${fileName}`, 34 | Key: `public/movie/${fileName}`, 35 | ACL: 'public-read', 36 | }); 37 | 38 | await this.s3 39 | .deleteObject({ 40 | Bucket: bucketName, 41 | Key: `public/temp/${fileName}`, 42 | }); 43 | } catch (e) { 44 | console.log(e); 45 | throw new InternalServerErrorException('S3 에러!'); 46 | } 47 | } 48 | 49 | async createPresignedUrl(expiresIn = 300) { 50 | const params = { 51 | Bucket: this.configService.get(envVariableKeys.bucketName), 52 | Key: `public/temp/${Uuid()}.mp4`, 53 | ACL: ObjectCannedACL.public_read, 54 | }; 55 | 56 | try { 57 | const url = await getSignedUrl(this.s3, new PutObjectCommand(params), { 58 | expiresIn, 59 | }); 60 | 61 | return url; 62 | } catch (error) { 63 | console.log(error); 64 | throw new InternalServerErrorException('S3 Presigned URL 생성 실패'); 65 | } 66 | } 67 | 68 | applyPagePaginationParamsToQb(qb: SelectQueryBuilder, dto: PagePaginationDto) { 69 | const { page, take } = dto; 70 | 71 | const skip = (page - 1) * take; 72 | 73 | qb.take(take); 74 | qb.skip(skip); 75 | } 76 | 77 | async applyCursorPaginationParamsToQb(qb: SelectQueryBuilder, dto: CursorPaginationDto) { 78 | let { cursor, take, order } = dto; 79 | 80 | if (cursor) { 81 | const decodedCursor = Buffer.from(cursor, 'base64').toString('utf-8'); 82 | 83 | /** 84 | * { 85 | * values : { 86 | * id: 27 87 | * }, 88 | * order: ['id_DESC'] 89 | * } 90 | */ 91 | const cursorObj = JSON.parse(decodedCursor); 92 | 93 | order = cursorObj.order; 94 | 95 | const { values } = cursorObj; 96 | 97 | /// WHERE (column1 > value1) 98 | /// OR (column1 = value1 AND column2 < value2) 99 | /// OR (column1 = value1 AND column2 = value2 AND column3 > value3) 100 | /// (movie.column1, movie.column2, movie.column3) > (:value1, :value2, :value3) 101 | 102 | const columns = Object.keys(values); 103 | const comparisonOperator = order.some((o) => o.endsWith('DESC')) ? '<' : '>'; 104 | const whereConditions = columns.map(c => `${qb.alias}.${c}`).join(','); 105 | const whereParams = columns.map(c => `:${c}`).join(',') 106 | 107 | qb.where(`(${whereConditions}) ${comparisonOperator} (${whereParams})`, values); 108 | } 109 | 110 | // ["likeCount_DESC", "id_DESC"] 111 | for (let i = 0; i < order.length; i++) { 112 | const [column, direction] = order[i].split('_'); 113 | 114 | if (direction !== 'ASC' && direction !== 'DESC') { 115 | throw new BadRequestException('Order는 ASC 또는 DESC으로 입력해주세요!'); 116 | } 117 | 118 | if (i === 0) { 119 | qb.orderBy(`${qb.alias}.${column}`, direction) 120 | } else { 121 | qb.addOrderBy(`${qb.alias}.${column}`, direction); 122 | } 123 | } 124 | 125 | qb.take(take); 126 | 127 | const results = await qb.getMany(); 128 | 129 | const nextCursor = this.generateNextCursor(results, order); 130 | 131 | return { qb, nextCursor }; 132 | } 133 | 134 | generateNextCursor(results: T[], order: string[]): string | null { 135 | if (results.length === 0) return null; 136 | 137 | /** 138 | * { 139 | * values : { 140 | * id: 27 141 | * }, 142 | * order: ['id_DESC'] 143 | * } 144 | */ 145 | 146 | const lastItme = results[results.length - 1]; 147 | 148 | const values = {}; 149 | 150 | order.forEach((columnOrder) => { 151 | const [column] = columnOrder.split('_') 152 | values[column] = lastItme[column]; 153 | }); 154 | 155 | const cursorObj = { values, order }; 156 | const nextCursor = Buffer.from(JSON.stringify(cursorObj)).toString('base64'); 157 | 158 | return nextCursor; 159 | } 160 | } -------------------------------------------------------------------------------- /src/common/const/env.const.ts: -------------------------------------------------------------------------------- 1 | const env = 'ENV'; 2 | const dbType = 'DB_TYPE'; 3 | const dbHost = 'DB_HOST'; 4 | const dbPort = 'DB_PORT'; 5 | const dbUsername = 'DB_USERNAME'; 6 | const dbPassword = 'DB_PASSWORD'; 7 | const dbDatabase = 'DB_DATABASE'; 8 | const dbUrl = 'DB_URL'; 9 | const hashRounds = 'HASH_ROUNDS'; 10 | const accessTokenSecret = 'ACCESS_TOKEN_SECRET'; 11 | const refreshTokenSecret = 'REFRESH_TOKEN_SECRET'; 12 | const awsSecretAccessKey = 'AWS_SECRET_ACCESS_KEY'; 13 | const awsAccessKeyId = 'AWS_ACCESS_KEY_ID'; 14 | const awsRegion = 'AWS_REGION'; 15 | const bucketName = 'BUCKET_NAME' 16 | 17 | export const envVariableKeys = { 18 | env, 19 | dbType, 20 | dbHost, 21 | dbPort, 22 | dbUsername, 23 | dbPassword, 24 | dbDatabase, 25 | dbUrl, 26 | hashRounds, 27 | accessTokenSecret, 28 | refreshTokenSecret, 29 | awsSecretAccessKey, 30 | awsAccessKeyId, 31 | awsRegion, 32 | bucketName 33 | } -------------------------------------------------------------------------------- /src/common/decorator/query-runner.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext, InternalServerErrorException } from "@nestjs/common"; 2 | 3 | export const QueryRunner = createParamDecorator( 4 | (data: any, context: ExecutionContext) => { 5 | const request = context.switchToHttp().getRequest(); 6 | 7 | if(!request || !request.queryRunner){ 8 | throw new InternalServerErrorException('Query Runner 객체를 찾을 수 없습니다!') 9 | } 10 | 11 | return request.queryRunner; 12 | } 13 | ); -------------------------------------------------------------------------------- /src/common/decorator/throttle.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from "@nestjs/core"; 2 | 3 | export const Throttle = Reflector.createDecorator<{ 4 | count: number, 5 | unit: 'minute' 6 | }>(); -------------------------------------------------------------------------------- /src/common/decorator/ws-query-runner.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext, InternalServerErrorException } from "@nestjs/common"; 2 | 3 | export const WsQueryRunner = createParamDecorator( 4 | (data: any, context: ExecutionContext) => { 5 | const client = context.switchToWs().getClient(); 6 | 7 | if(!client || !client.data || !client.data.queryRunner){ 8 | throw new InternalServerErrorException('Query Runner 객체를 찾을 수 없습니다!') 9 | } 10 | 11 | return client.data.queryRunner; 12 | } 13 | ); -------------------------------------------------------------------------------- /src/common/dto/cursor-pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { Transform } from "class-transformer"; 3 | import { IsArray, IsIn, IsInt, IsOptional, IsString } from "class-validator"; 4 | 5 | export class CursorPaginationDto{ 6 | @IsString() 7 | @IsOptional() 8 | @ApiProperty({ 9 | description: '페이지네이션 커서', 10 | example: 'eyJ2YWx1ZXMiOnsiaWQiOjN9LCJvcmRlciI6WyJpZF9ERVNDIl19', 11 | }) 12 | // id_52,likeCount_20 13 | // 52 14 | cursor?: string; 15 | 16 | @IsArray() 17 | @IsString({ 18 | each: true, 19 | }) 20 | @IsOptional() 21 | @ApiProperty({ 22 | description: '내림차 또는 오름차 정렬', 23 | example: ['id_DESC'] 24 | }) 25 | @Transform(({value}) => (Array.isArray(value) ? value : [value])) 26 | // id_ASC id_DESC 27 | // [id_DESC, likeCount_DESC] 28 | order: string[] = ['id_DESC']; 29 | 30 | @IsInt() 31 | @IsOptional() 32 | @ApiProperty({ 33 | description: '가져올 데이터 갯수', 34 | example: 5, 35 | }) 36 | take: number = 2; 37 | } -------------------------------------------------------------------------------- /src/common/dto/page-pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsOptional } from "class-validator"; 2 | 3 | export class PagePaginationDto { 4 | @IsInt() 5 | @IsOptional() 6 | page: number = 1; 7 | 8 | @IsInt() 9 | @IsOptional() 10 | take: number = 5; 11 | } -------------------------------------------------------------------------------- /src/common/entity/base-table.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiHideProperty } from "@nestjs/swagger"; 2 | import { Exclude } from "class-transformer"; 3 | import { CreateDateColumn, UpdateDateColumn, VersionColumn } from "typeorm"; 4 | 5 | export class BaseTable{ 6 | @CreateDateColumn() 7 | @Exclude() 8 | @ApiHideProperty() 9 | createdAt: Date; 10 | 11 | @UpdateDateColumn() 12 | @Exclude() 13 | @ApiHideProperty() 14 | updatedAt: Date; 15 | 16 | @VersionColumn() 17 | @Exclude() 18 | @ApiHideProperty() 19 | version: number; 20 | } -------------------------------------------------------------------------------- /src/common/filter/forbidden.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException } from "@nestjs/common"; 2 | 3 | @Catch(ForbiddenException) 4 | export class ForbiddenExceptionFilter implements ExceptionFilter { 5 | catch(exception: any, host: ArgumentsHost) { 6 | const ctx = host.switchToHttp(); 7 | const response = ctx.getResponse(); 8 | const request = ctx.getRequest(); 9 | 10 | const status = exception.getStatus(); 11 | 12 | console.log(`[UnauthorizedException] ${request.method} ${request.path}`); 13 | 14 | response.status(status) 15 | .json({ 16 | statusCode: status, 17 | timestamp: new Date().toISOString(), 18 | path: request.url, 19 | message: '권한이 없습니다!!!', 20 | }) 21 | } 22 | } -------------------------------------------------------------------------------- /src/common/filter/query-failed.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common"; 2 | import { QueryFailedError } from "typeorm"; 3 | 4 | @Catch(QueryFailedError) 5 | export class QueryFailedExceptionFilter implements ExceptionFilter{ 6 | catch(exception: any, host: ArgumentsHost) { 7 | const ctx = host.switchToHttp(); 8 | const response = ctx.getResponse(); 9 | const request = ctx.getRequest(); 10 | 11 | const status = 400; 12 | 13 | console.log(exception); 14 | 15 | let message = '데이터베이스 에러 발생!'; 16 | 17 | if(exception.message.includes('duplicate key')){ 18 | message = '중복 키 에러!'; 19 | } 20 | 21 | response.status(status).json({ 22 | statusCode: status, 23 | timestamp: new Date().toISOString(), 24 | path: request.url, 25 | message, 26 | }) 27 | } 28 | } -------------------------------------------------------------------------------- /src/common/interceptor/cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; 2 | import { Observable, of, tap } from "rxjs"; 3 | 4 | @Injectable() 5 | export class CacheInterceptor implements NestInterceptor { 6 | private cache = new Map(); 7 | 8 | intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { 9 | const request = context.switchToHttp().getRequest(); 10 | 11 | /// GET /movie 12 | const key = `${request.method}-${request.path}`; 13 | 14 | if(this.cache.has(key)){ 15 | return of(this.cache.get(key)); 16 | } 17 | 18 | return next.handle() 19 | .pipe( 20 | tap(response => this.cache.set(key, response)), 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /src/common/interceptor/response-time.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, InternalServerErrorException, NestInterceptor } from "@nestjs/common"; 2 | import { delay, Observable, tap } from "rxjs"; 3 | 4 | @Injectable() 5 | export class ResponseTimeInterceptor implements NestInterceptor { 6 | intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { 7 | const req = context.switchToHttp().getRequest(); 8 | 9 | const reqTime = Date.now(); 10 | 11 | return next.handle() 12 | .pipe( 13 | tap(() => { 14 | const respTime = Date.now(); 15 | const diff = respTime - reqTime; 16 | 17 | console.log(`[${req.method} ${req.path}] ${diff}ms`) 18 | }), 19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /src/common/interceptor/throttle.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; 2 | import { CallHandler, ExecutionContext, ForbiddenException, Inject, Injectable, NestInterceptor } from "@nestjs/common"; 3 | import { Reflector } from "@nestjs/core"; 4 | import { Observable, tap } from "rxjs"; 5 | import { Throttle } from "../decorator/throttle.decorator"; 6 | 7 | @Injectable() 8 | export class ThrottleInterceptor implements NestInterceptor { 9 | constructor( 10 | @Inject(CACHE_MANAGER) 11 | private readonly cacheManager: Cache, 12 | private readonly reflector: Reflector, 13 | ) { } 14 | 15 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 16 | const request = context.switchToHttp().getRequest(); 17 | 18 | /// URL_USERID_MINUTE 19 | /// VALUE -> count 20 | 21 | const userId = request?.user?.sub; 22 | 23 | if (!userId) { 24 | return next.handle(); 25 | } 26 | 27 | const throttleOptions = this.reflector.get<{ 28 | count: number, 29 | unit: 'minute', 30 | }>(Throttle, context.getHandler()); 31 | 32 | if (!throttleOptions) { 33 | return next.handle(); 34 | } 35 | 36 | const date = new Date(); 37 | const minute = date.getMinutes(); 38 | 39 | const key = `${request.method}_${request.path}_${userId}_${minute}`; 40 | 41 | const count = await this.cacheManager.get(key); 42 | 43 | console.log(key); 44 | console.log(count); 45 | 46 | if (count && count >= throttleOptions.count) { 47 | throw new ForbiddenException('요청 가능 횟수를 넘어섰습니다!'); 48 | } 49 | 50 | return next.handle() 51 | .pipe( 52 | tap( 53 | async () => { 54 | const count = await this.cacheManager.get(key) ?? 0; 55 | 56 | await this.cacheManager.set(key, count + 1, 60000); 57 | } 58 | ) 59 | ) 60 | } 61 | } -------------------------------------------------------------------------------- /src/common/interceptor/transaction.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; 2 | import { catchError, Observable, tap } from "rxjs"; 3 | import { DataSource } from "typeorm"; 4 | 5 | @Injectable() 6 | export class TransactionInterceptor implements NestInterceptor { 7 | constructor( 8 | private readonly dataSource: DataSource, 9 | ) { } 10 | 11 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 12 | const req = context.switchToHttp().getRequest(); 13 | 14 | const qr = this.dataSource.createQueryRunner(); 15 | 16 | await qr.connect(); 17 | await qr.startTransaction(); 18 | 19 | req.queryRunner = qr; 20 | 21 | return next.handle() 22 | .pipe( 23 | catchError( 24 | async (e)=>{ 25 | await qr.rollbackTransaction(); 26 | await qr.release(); 27 | 28 | throw e; 29 | } 30 | ), 31 | tap(async ()=> { 32 | await qr.commitTransaction(); 33 | await qr.release(); 34 | }), 35 | ); 36 | } 37 | } -------------------------------------------------------------------------------- /src/common/interceptor/ws-transaction.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; 2 | import { catchError, Observable, tap } from "rxjs"; 3 | import { DataSource } from "typeorm"; 4 | 5 | @Injectable() 6 | export class WsTransactionInterceptor implements NestInterceptor { 7 | constructor( 8 | private readonly dataSource: DataSource, 9 | ) { } 10 | 11 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 12 | const client = context.switchToWs().getClient(); 13 | 14 | const qr = this.dataSource.createQueryRunner(); 15 | 16 | await qr.connect(); 17 | await qr.startTransaction(); 18 | 19 | client.data.queryRunner = qr; 20 | 21 | return next.handle() 22 | .pipe( 23 | catchError( 24 | async (e)=>{ 25 | await qr.rollbackTransaction(); 26 | await qr.release(); 27 | 28 | throw e; 29 | } 30 | ), 31 | tap(async ()=> { 32 | await qr.commitTransaction(); 33 | await qr.release(); 34 | }), 35 | ); 36 | } 37 | } -------------------------------------------------------------------------------- /src/common/logger/default.logger.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger, Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class DefaultLogger extends ConsoleLogger{ 5 | warn(message: unknown, ...rest: unknown[]): void { 6 | console.log('---- WARN LOG ----'); 7 | super.warn(message, ...rest); 8 | } 9 | 10 | error(message: unknown, ...rest: unknown[]): void { 11 | console.log('---- ERROR LOG ----'); 12 | super.error(message, ...rest); 13 | } 14 | } -------------------------------------------------------------------------------- /src/common/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from "@nestjs/common"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit{ 6 | constructor(){ 7 | super({ 8 | omit:{ 9 | user:{ 10 | password: true, 11 | } 12 | } 13 | }) 14 | } 15 | 16 | async onModuleInit() { 17 | await this.$connect(); 18 | } 19 | } -------------------------------------------------------------------------------- /src/common/tasks.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, LoggerService } from "@nestjs/common"; 2 | import { Cron, SchedulerRegistry } from "@nestjs/schedule"; 3 | import { InjectRepository } from "@nestjs/typeorm"; 4 | import { readdir, unlink } from "fs/promises"; 5 | import { join, parse } from 'path'; 6 | import { Movie } from "src/movie/entity/movie.entity"; 7 | import { Repository } from "typeorm"; 8 | import { Logger } from "@nestjs/common"; 9 | import { DefaultLogger } from "./logger/default.logger"; 10 | import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston"; 11 | 12 | @Injectable() 13 | export class TasksService { 14 | // private readonly logger = new Logger(TasksService.name); 15 | 16 | constructor( 17 | @InjectRepository(Movie) 18 | private readonly movieRepository: Repository, 19 | private readonly schedulerRegistry: SchedulerRegistry, 20 | // private readonly logger: DefaultLogger, 21 | @Inject(WINSTON_MODULE_NEST_PROVIDER) 22 | private readonly logger: LoggerService, 23 | ) { } 24 | 25 | // @Cron('*/5 * * * * *') 26 | logEverySecond() { 27 | this.logger.fatal('FATAL 레벨 로그', null, TasksService.name); 28 | this.logger.error('ERROR 레벨 로그', null, TasksService.name); 29 | this.logger.warn('WARN 레벨 로그', TasksService.name); 30 | this.logger.log('LOG 레벨 로그', TasksService.name); 31 | this.logger.debug('DEBUG 레벨 로그', TasksService.name); 32 | this.logger.verbose('VERBOSE 레벨 로그', TasksService.name); 33 | } 34 | 35 | // @Cron('* * * * * *') 36 | async eraseOrphanFiles() { 37 | const files = await readdir(join(process.cwd(), 'public', 'temp')); 38 | 39 | const deleteFilesTargets = files.filter((file) => { 40 | const filename = parse(file).name; 41 | 42 | const split = filename.split('_'); 43 | 44 | if (split.length !== 2) { 45 | return true; 46 | } 47 | 48 | try { 49 | const date = +new Date(parseInt(split[split.length - 1])); 50 | const aDayInMilSec = (24 * 60 * 60 * 1000); 51 | 52 | const now = +new Date(); 53 | 54 | return (now - date) > aDayInMilSec; 55 | 56 | } catch (e) { 57 | return true; 58 | } 59 | }); 60 | 61 | await Promise.all( 62 | deleteFilesTargets.map( 63 | (x) => unlink(join(process.cwd(), 'public', 'temp', x)) 64 | ) 65 | ); 66 | } 67 | 68 | // @Cron('0 * * * * *') 69 | async calculateMovieLikeCounts() { 70 | console.log('run'); 71 | await this.movieRepository.query( 72 | ` 73 | UPDATE movie m 74 | SET "likeCount" = ( 75 | SELECT count(*) FROM movie_user_like mul 76 | WHERE m.id = mul."movieId" AND mul."isLike" = true 77 | )` 78 | ) 79 | 80 | await this.movieRepository.query( 81 | ` 82 | UPDATE movie m 83 | SET "dislikeCount" = ( 84 | SELECT count(*) FROM movie_user_like mul 85 | WHERE m.id = mul."movieId" AND mul."isLike" = false 86 | ) 87 | ` 88 | 89 | ) 90 | } 91 | 92 | // @Cron('* * * * * *', { 93 | // name: 'printer', 94 | // }) 95 | printer() { 96 | console.log('print every seconds'); 97 | } 98 | 99 | // @Cron('*/5 * * * * *') 100 | stopper() { 101 | console.log('---stopper run---'); 102 | 103 | const job = this.schedulerRegistry.getCronJob('printer'); 104 | 105 | // console.log('# Last Date'); 106 | // console.log(job.lastDate()); 107 | // console.log('# Next Date'); 108 | // console.log(job.nextDate()); 109 | console.log('# Next Dates'); 110 | console.log(job.nextDates(5)); 111 | 112 | if (job.running) { 113 | job.stop(); 114 | } else { 115 | job.start(); 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/database/data-source.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { DataSource } from 'typeorm'; 3 | 4 | dotenv.config(); 5 | 6 | export default new DataSource({ 7 | type: process.env.DB_TYPE as 'postgres', 8 | host: process.env.DB_HOST, 9 | port: parseInt(process.env.DB_PORT || '5432'), 10 | username: process.env.DB_USERNAME, 11 | password: process.env.DB_PASSWORD, 12 | database: process.env.DB_DATABASE, 13 | synchronize: false, 14 | logging: false, 15 | entities:[ 16 | 'dist/**/*.entity.js' 17 | ], 18 | migrations: [ 19 | 'dist/database/migrations/*.js' 20 | ], 21 | ...(process.env.ENV === 'prod' && { 22 | ssl: { 23 | rejectUnauthorized: false, 24 | } 25 | }) 26 | }); -------------------------------------------------------------------------------- /src/database/migrations/1726493656200-init.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class Init1726493656200 implements MigrationInterface { 4 | name = 'Init1726493656200' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "movie_detail" ("id" SERIAL NOT NULL, "detail" character varying NOT NULL, CONSTRAINT "PK_e3014d1b25dbc9648b9abc58537" PRIMARY KEY ("id"))`); 8 | await queryRunner.query(`CREATE TABLE "director" ("createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "version" integer NOT NULL, "id" SERIAL NOT NULL, "name" character varying NOT NULL, "dob" TIMESTAMP NOT NULL, "nationality" character varying NOT NULL, CONSTRAINT "PK_b85b179882f31c43324ef124fea" PRIMARY KEY ("id"))`); 9 | await queryRunner.query(`CREATE TABLE "genre" ("createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "version" integer NOT NULL, "id" SERIAL NOT NULL, "name" character varying NOT NULL, CONSTRAINT "UQ_dd8cd9e50dd049656e4be1f7e8c" UNIQUE ("name"), CONSTRAINT "PK_0285d4f1655d080cfcf7d1ab141" PRIMARY KEY ("id"))`); 10 | await queryRunner.query(`CREATE TABLE "user" ("createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "version" integer NOT NULL, "id" SERIAL NOT NULL, "email" character varying NOT NULL, "password" character varying NOT NULL, "role" integer NOT NULL DEFAULT '2', CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`); 11 | await queryRunner.query(`CREATE TABLE "movie" ("createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "version" integer NOT NULL, "id" SERIAL NOT NULL, "title" character varying NOT NULL, "likeCount" integer NOT NULL DEFAULT '0', "dislikeCount" integer NOT NULL DEFAULT '0', "movieFilePath" character varying NOT NULL, "creatorId" integer, "detailId" integer NOT NULL, "directorId" integer NOT NULL, CONSTRAINT "UQ_a81090ad0ceb645f30f9399c347" UNIQUE ("title"), CONSTRAINT "REL_87276a4fc1647d6db559f61f89" UNIQUE ("detailId"), CONSTRAINT "PK_cb3bb4d61cf764dc035cbedd422" PRIMARY KEY ("id"))`); 12 | await queryRunner.query(`CREATE TABLE "movie_user_like" ("movieId" integer NOT NULL, "userId" integer NOT NULL, "isLike" boolean NOT NULL, CONSTRAINT "PK_55397b3cefaa6fc1b47370fe84e" PRIMARY KEY ("movieId", "userId"))`); 13 | await queryRunner.query(`CREATE TABLE "movie_genres_genre" ("movieId" integer NOT NULL, "genreId" integer NOT NULL, CONSTRAINT "PK_aee18568f9fe4ecca74f35891af" PRIMARY KEY ("movieId", "genreId"))`); 14 | await queryRunner.query(`CREATE INDEX "IDX_985216b45541c7e0ec644a8dd4" ON "movie_genres_genre" ("movieId") `); 15 | await queryRunner.query(`CREATE INDEX "IDX_1996ce31a9e067304ab168d671" ON "movie_genres_genre" ("genreId") `); 16 | await queryRunner.query(`ALTER TABLE "movie" ADD CONSTRAINT "FK_b55916de756e46290d52c70fc04" FOREIGN KEY ("creatorId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 17 | await queryRunner.query(`ALTER TABLE "movie" ADD CONSTRAINT "FK_87276a4fc1647d6db559f61f89a" FOREIGN KEY ("detailId") REFERENCES "movie_detail"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 18 | await queryRunner.query(`ALTER TABLE "movie" ADD CONSTRAINT "FK_a32a80a88aff67851cf5b75d1cb" FOREIGN KEY ("directorId") REFERENCES "director"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 19 | await queryRunner.query(`ALTER TABLE "movie_user_like" ADD CONSTRAINT "FK_fd47c2914ce011f6966368c8486" FOREIGN KEY ("movieId") REFERENCES "movie"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); 20 | await queryRunner.query(`ALTER TABLE "movie_user_like" ADD CONSTRAINT "FK_6a4d1cde9def796ad01b9ede541" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); 21 | await queryRunner.query(`ALTER TABLE "movie_genres_genre" ADD CONSTRAINT "FK_985216b45541c7e0ec644a8dd4e" FOREIGN KEY ("movieId") REFERENCES "movie"("id") ON DELETE CASCADE ON UPDATE CASCADE`); 22 | await queryRunner.query(`ALTER TABLE "movie_genres_genre" ADD CONSTRAINT "FK_1996ce31a9e067304ab168d6715" FOREIGN KEY ("genreId") REFERENCES "genre"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 23 | } 24 | 25 | public async down(queryRunner: QueryRunner): Promise { 26 | await queryRunner.query(`ALTER TABLE "movie_genres_genre" DROP CONSTRAINT "FK_1996ce31a9e067304ab168d6715"`); 27 | await queryRunner.query(`ALTER TABLE "movie_genres_genre" DROP CONSTRAINT "FK_985216b45541c7e0ec644a8dd4e"`); 28 | await queryRunner.query(`ALTER TABLE "movie_user_like" DROP CONSTRAINT "FK_6a4d1cde9def796ad01b9ede541"`); 29 | await queryRunner.query(`ALTER TABLE "movie_user_like" DROP CONSTRAINT "FK_fd47c2914ce011f6966368c8486"`); 30 | await queryRunner.query(`ALTER TABLE "movie" DROP CONSTRAINT "FK_a32a80a88aff67851cf5b75d1cb"`); 31 | await queryRunner.query(`ALTER TABLE "movie" DROP CONSTRAINT "FK_87276a4fc1647d6db559f61f89a"`); 32 | await queryRunner.query(`ALTER TABLE "movie" DROP CONSTRAINT "FK_b55916de756e46290d52c70fc04"`); 33 | await queryRunner.query(`DROP INDEX "public"."IDX_1996ce31a9e067304ab168d671"`); 34 | await queryRunner.query(`DROP INDEX "public"."IDX_985216b45541c7e0ec644a8dd4"`); 35 | await queryRunner.query(`DROP TABLE "movie_genres_genre"`); 36 | await queryRunner.query(`DROP TABLE "movie_user_like"`); 37 | await queryRunner.query(`DROP TABLE "movie"`); 38 | await queryRunner.query(`DROP TABLE "user"`); 39 | await queryRunner.query(`DROP TABLE "genre"`); 40 | await queryRunner.query(`DROP TABLE "director"`); 41 | await queryRunner.query(`DROP TABLE "movie_detail"`); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/database/migrations/1726493905165-test.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class Test1726493905165 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.query(`CREATE TABLE "test" (id SERIAL)`); 7 | } 8 | 9 | public async down(queryRunner: QueryRunner): Promise { 10 | await queryRunner.query(`DROP TABLE "test"`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/director/director.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DirectorController } from './director.controller'; 3 | import { DirectorService } from './director.service'; 4 | import { CreateDirectorDto } from './dto/create-director.dto'; 5 | 6 | const mockDirectorService = { 7 | findAll: jest.fn(), 8 | findOne: jest.fn(), 9 | create: jest.fn(), 10 | update: jest.fn(), 11 | remove: jest.fn(), 12 | }; 13 | 14 | describe('DirectorController', () => { 15 | let controller: DirectorController; 16 | let service: DirectorService; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | controllers: [DirectorController], 21 | providers: [ 22 | { 23 | provide: DirectorService, 24 | useValue: mockDirectorService, 25 | } 26 | ], 27 | }).compile(); 28 | 29 | controller = module.get(DirectorController); 30 | service = module.get(DirectorService); 31 | }); 32 | 33 | it('should be defined', () => { 34 | expect(controller).toBeDefined(); 35 | }); 36 | 37 | describe('findAll', () => { 38 | it('should call findAll method from DirectorService', () => { 39 | const result = [{ id: 1, name: 'codefactory' }]; 40 | jest.spyOn(mockDirectorService, 'findAll').mockResolvedValue(result); 41 | 42 | expect(controller.findAll()).resolves.toEqual(result); 43 | expect(service.findAll).toHaveBeenCalled(); 44 | }); 45 | }); 46 | 47 | describe('findOne', () => { 48 | it('should call findOne method from DirectorService with correct ID', () => { 49 | const result = { id: 1, name: 'codefactory' }; 50 | 51 | jest.spyOn(mockDirectorService, 'findOne').mockResolvedValue(result); 52 | 53 | expect(controller.findOne(1)).resolves.toEqual(result); 54 | expect(service.findOne).toHaveBeenCalledWith(1); 55 | }) 56 | }); 57 | 58 | describe('create', () => { 59 | it('should call create method from DirectorService with correct DTO', () => { 60 | const createDirectoDto = { name: 'code factory' }; 61 | const result = { id: 1, name: 'code factory' }; 62 | 63 | jest.spyOn(mockDirectorService, 'create').mockResolvedValue(result); 64 | 65 | expect(controller.create(createDirectoDto as CreateDirectorDto)).resolves.toEqual(result); 66 | expect(service.create).toHaveBeenCalledWith(createDirectoDto); 67 | }) 68 | }); 69 | 70 | describe('update', () => { 71 | it('should call update method from DirectorService with correct ID and DTO', async () => { 72 | const updateDirectorDto = { name: 'Code Factory' }; 73 | const result = { 74 | id: 1, 75 | name: 'Code Factory', 76 | } 77 | jest.spyOn(mockDirectorService, 'update').mockResolvedValue(result); 78 | 79 | expect(controller.update(1, updateDirectorDto)).resolves.toEqual(result); 80 | expect(service.update).toHaveBeenCalledWith(1, updateDirectorDto); 81 | }); 82 | }) 83 | 84 | describe('remove', ()=> { 85 | it('should call remove method from DirectorService with correct ID', async ()=> { 86 | const result = 1; 87 | jest.spyOn(mockDirectorService, 'remove').mockResolvedValue(result); 88 | 89 | expect(controller.remove(1)).resolves.toEqual(result); 90 | expect(service.remove).toHaveBeenCalledWith(1); 91 | }) 92 | }) 93 | }); 94 | -------------------------------------------------------------------------------- /src/director/director.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, Delete, UseInterceptors, ClassSerializerInterceptor, ParseIntPipe } from '@nestjs/common'; 2 | import { DirectorService } from './director.service'; 3 | import { CreateDirectorDto } from './dto/create-director.dto'; 4 | import { UpdateDirectorDto } from './dto/update-director.dto'; 5 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 6 | 7 | @Controller('director') 8 | @ApiBearerAuth() 9 | @ApiTags('director') 10 | // @UseInterceptors(ClassSerializerInterceptor) 11 | export class DirectorController { 12 | constructor(private readonly directorService: DirectorService) {} 13 | 14 | @Get() 15 | findAll() { 16 | return this.directorService.findAll(); 17 | } 18 | 19 | @Get(':id') 20 | findOne(@Param('id') id: string) { 21 | return this.directorService.findOne(id); 22 | } 23 | 24 | @Post() 25 | create(@Body() createDirectorDto: CreateDirectorDto) { 26 | return this.directorService.create(createDirectorDto); 27 | } 28 | 29 | @Patch(':id') 30 | update(@Param('id') id: string, @Body() updateDirectorDto: UpdateDirectorDto) { 31 | return this.directorService.update(id, updateDirectorDto); 32 | } 33 | 34 | @Delete(':id') 35 | remove(@Param('id') id: string) { 36 | return this.directorService.remove(id); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/director/director.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DirectorService } from './director.service'; 3 | import { DirectorController } from './director.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | // import { Director } from './entity/director.entity'; 6 | import { CommonModule } from 'src/common/common.module'; 7 | import { MongooseModule } from '@nestjs/mongoose'; 8 | import { Director, DirectorSchema } from './schema/director.schema'; 9 | 10 | @Module({ 11 | imports: [ 12 | // TypeOrmModule.forFeature( 13 | // [ 14 | // Director, 15 | // ] 16 | // ), 17 | MongooseModule.forFeature([ 18 | { 19 | name: Director.name, 20 | schema: DirectorSchema, 21 | } 22 | ]), 23 | CommonModule, 24 | ], 25 | controllers: [DirectorController], 26 | providers: [DirectorService], 27 | }) 28 | export class DirectorModule { } 29 | -------------------------------------------------------------------------------- /src/director/director.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DirectorService } from './director.service'; 3 | import { Repository } from 'typeorm'; 4 | import { Director } from './entity/director.entity'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { CreateDirectorDto } from './dto/create-director.dto'; 7 | import { ResponseTimeInterceptor } from 'src/common/interceptor/response-time.interceptor'; 8 | import { NotFoundException } from '@nestjs/common'; 9 | 10 | const mockDirectorRepository = { 11 | save: jest.fn(), 12 | find: jest.fn(), 13 | findOne: jest.fn(), 14 | update: jest.fn(), 15 | delete: jest.fn(), 16 | } 17 | 18 | describe('DirectorService', () => { 19 | let directorService: DirectorService; 20 | let directorRepository: Repository; 21 | 22 | beforeEach(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | providers: [ 25 | DirectorService, 26 | { 27 | provide: getRepositoryToken(Director), 28 | useValue: mockDirectorRepository, 29 | } 30 | ], 31 | }).compile(); 32 | 33 | directorService = module.get(DirectorService); 34 | directorRepository = module.get>(getRepositoryToken(Director)); 35 | }); 36 | 37 | beforeAll(()=>{ 38 | jest.clearAllMocks(); 39 | }) 40 | 41 | it('should be defined', () => { 42 | expect(directorService).toBeDefined(); 43 | }); 44 | 45 | describe("create", ()=>{ 46 | it('should create a new director', async ()=>{ 47 | const createDirectorDto = { 48 | name: 'Code Factory', 49 | }; 50 | jest.spyOn(mockDirectorRepository, 'save').mockResolvedValue(createDirectorDto); 51 | 52 | const result = await directorService.create(createDirectorDto as CreateDirectorDto); 53 | 54 | expect(directorRepository.save).toHaveBeenCalledWith(createDirectorDto); 55 | expect(result).toEqual(createDirectorDto); 56 | }); 57 | }); 58 | 59 | describe('findAll', ()=>{ 60 | it('should return an array of directors', async ()=>{ 61 | const directors = [ 62 | { 63 | id: 1, 64 | name: 'code factory', 65 | } 66 | ]; 67 | 68 | jest.spyOn(mockDirectorRepository, 'find').mockResolvedValue(directors); 69 | 70 | const result = await directorService.findAll(); 71 | 72 | expect(directorRepository.find).toHaveBeenCalled(); 73 | expect(result).toEqual(directors); 74 | }) 75 | }); 76 | 77 | describe('findOne', ()=>{ 78 | it('should return a single director by id', async ()=>{ 79 | const director = {id: 1, name: 'Code Factory'}; 80 | 81 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValue(director as Director); 82 | 83 | const result = await directorService.findOne(director.id); 84 | 85 | expect(directorRepository.findOne).toHaveBeenCalledWith({ 86 | where: {id: 1} 87 | }); 88 | expect(result).toEqual(director); 89 | }); 90 | }); 91 | 92 | describe('update', ()=>{ 93 | it('should update a director', async ()=>{ 94 | const updateDirectorDto = {name: 'code factory'}; 95 | const existingDirector = {id: 1, name: 'code factory'}; 96 | const updatedDirector = {id: 1, name: 'code factory 2'}; 97 | 98 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValueOnce(existingDirector); 99 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValueOnce(updatedDirector); 100 | 101 | const result = await directorService.update(1, updateDirectorDto); 102 | 103 | expect(directorRepository.findOne).toHaveBeenCalledWith({ 104 | where: {id: 1} 105 | }); 106 | expect(directorRepository.update).toHaveBeenCalledWith({ 107 | id: 1 108 | }, updateDirectorDto); 109 | expect(result).toEqual(updatedDirector); 110 | }); 111 | 112 | it('should throw NotFoundException if director does not exist', ()=>{ 113 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValue(null); 114 | 115 | expect(directorService.update(1, {name: 'code factory'})).rejects.toThrow( 116 | NotFoundException, 117 | ) 118 | }) 119 | }); 120 | 121 | describe('remove', ()=>{ 122 | it('shouuld remove a director by id', async ()=>{ 123 | const director = {id: 1, name: 'code factory'}; 124 | 125 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValue(director); 126 | 127 | const result = await directorService.remove(1); 128 | 129 | expect(directorRepository.findOne).toHaveBeenCalledWith({ 130 | where:{ 131 | id: 1, 132 | } 133 | }); 134 | expect(directorRepository.delete).toHaveBeenCalledWith(1); 135 | expect(result).toEqual(1); 136 | }); 137 | 138 | it('should throw NotFoundException if director does not exist', async ()=>{ 139 | jest.spyOn(mockDirectorRepository, 'findOne').mockResolvedValue(null); 140 | 141 | expect(directorService.remove(1)).rejects.toThrow(NotFoundException); 142 | }); 143 | }) 144 | }); 145 | -------------------------------------------------------------------------------- /src/director/director.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { CreateDirectorDto } from './dto/create-director.dto'; 3 | import { UpdateDirectorDto } from './dto/update-director.dto'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { Director } from './schema/director.schema'; 6 | import { Repository } from 'typeorm'; 7 | import { PrismaService } from 'src/common/prisma.service'; 8 | import { InjectModel } from '@nestjs/mongoose'; 9 | import { Model } from 'mongoose'; 10 | 11 | @Injectable() 12 | export class DirectorService { 13 | constructor( 14 | // @InjectRepository(Director) 15 | // private readonly directorRepository: Repository, 16 | // private readonly prisma: PrismaService, 17 | @InjectModel(Director.name) 18 | private readonly directorModel: Model 19 | ){} 20 | 21 | create(createDirectorDto: CreateDirectorDto) { 22 | return this.directorModel.create(createDirectorDto); 23 | // return this.prisma.director.create({ 24 | // data:createDirectorDto, 25 | // }); 26 | // return this.directorRepository.save(createDirectorDto); 27 | } 28 | 29 | findAll() { 30 | return this.directorModel.find(); 31 | // return this.prisma.director.findMany(); 32 | // return this.directorRepository.find(); 33 | } 34 | 35 | findOne(id: string) { 36 | return this.directorModel.findById(id); 37 | // return this.prisma.director.findUnique({ 38 | // where:{ 39 | // id, 40 | // } 41 | // }) 42 | // return this.directorRepository.findOne({ 43 | // where:{ 44 | // id, 45 | // }, 46 | // }) 47 | } 48 | 49 | async update(id: string, updateDirectorDto: UpdateDirectorDto) { 50 | const director = await this.directorModel.findById(id); 51 | // const director = await this.prisma.director.findUnique({ 52 | // where:{ 53 | // id, 54 | // }, 55 | // }) 56 | // const director = await this.directorRepository.findOne({ 57 | // where:{ 58 | // id, 59 | // }, 60 | // }); 61 | 62 | if(!director){ 63 | throw new NotFoundException('존재하지 않는 ID의 영화입니다!'); 64 | } 65 | 66 | await this.directorModel.findByIdAndUpdate(id, updateDirectorDto).exec(); 67 | // await this.prisma.director.update({ 68 | // where:{ 69 | // id, 70 | // }, 71 | // data:{ 72 | // ...updateDirectorDto, 73 | 74 | // } 75 | // }) 76 | // await this.directorRepository.update( 77 | // { 78 | // id, 79 | // }, 80 | // { 81 | // ...updateDirectorDto, 82 | // } 83 | // ); 84 | 85 | const newDirector = await this.directorModel.findById(id); 86 | // const newDirector = await this.prisma.director.findUnique({ 87 | // where:{ 88 | // id, 89 | // }, 90 | // }) 91 | // const newDirector = await this.directorRepository.findOne({ 92 | // where:{ 93 | // id, 94 | // }, 95 | // }); 96 | 97 | return newDirector; 98 | } 99 | 100 | async remove(id: string) { 101 | const director = await this.directorModel.findById(id); 102 | // const director = await this.prisma.director.findUnique({ 103 | // where:{ 104 | // id, 105 | // }, 106 | // }) 107 | // const director = await this.directorRepository.findOne({ 108 | // where:{ 109 | // id, 110 | // }, 111 | // }); 112 | 113 | if(!director){ 114 | throw new NotFoundException('존재하지 않는 ID의 영화입니다!'); 115 | } 116 | 117 | await this.directorModel.findByIdAndDelete(id); 118 | // await this.prisma.director.delete({ 119 | // where:{ 120 | // id 121 | // } 122 | // }) 123 | // await this.directorRepository.delete(id); 124 | 125 | return id; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/director/dto/create-director.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { IsDate, IsDateString, IsNotEmpty, IsString } from "class-validator"; 3 | 4 | export class CreateDirectorDto { 5 | @IsNotEmpty() 6 | @IsString() 7 | name: string; 8 | 9 | @IsNotEmpty() 10 | @IsDate() 11 | dob: Date; 12 | 13 | @IsNotEmpty() 14 | @IsString() 15 | nationality: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/director/dto/update-director.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateDirectorDto } from './create-director.dto'; 3 | import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 4 | 5 | export class UpdateDirectorDto extends PartialType(CreateDirectorDto){} 6 | -------------------------------------------------------------------------------- /src/director/entity/director.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseTable } from "src/common/entity/base-table.entity"; 2 | import { Movie } from "src/movie/entity/movie.entity"; 3 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; 4 | 5 | @Entity() 6 | export class Director extends BaseTable{ 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column() 11 | name: string; 12 | 13 | @Column() 14 | dob: Date; 15 | 16 | @Column() 17 | nationality: string; 18 | 19 | @OneToMany( 20 | ()=> Movie, 21 | movie => movie.director, 22 | ) 23 | movies: Movie[]; 24 | } 25 | -------------------------------------------------------------------------------- /src/director/schema/director.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Movie } from "src/movie/schema/movie.schema"; 3 | import { Document, Types } from 'mongoose'; 4 | 5 | @Schema({ 6 | timestamps: true, 7 | }) 8 | export class Director extends Document{ 9 | @Prop({ 10 | required: true, 11 | }) 12 | name: string; 13 | 14 | @Prop({ 15 | required: true, 16 | }) 17 | dob: Date; 18 | 19 | @Prop({ 20 | required: true, 21 | }) 22 | nationality: string; 23 | 24 | @Prop({ 25 | type: [{ 26 | type: Types.ObjectId, 27 | ref: 'Movie', 28 | }] 29 | }) 30 | movies: Movie[]; 31 | } 32 | 33 | export const DirectorSchema = SchemaFactory.createForClass(Director); -------------------------------------------------------------------------------- /src/genre/dto/create-genre.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from "class-validator"; 2 | 3 | export class CreateGenreDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/genre/dto/update-genre.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateGenreDto } from './create-genre.dto'; 3 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 4 | 5 | export class UpdateGenreDto extends PartialType(CreateGenreDto){} 6 | -------------------------------------------------------------------------------- /src/genre/entity/genre.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseTable } from "src/common/entity/base-table.entity"; 2 | import { Movie } from "src/movie/entity/movie.entity"; 3 | import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm"; 4 | 5 | @Entity() 6 | export class Genre extends BaseTable{ 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column({ 11 | unique: true, 12 | }) 13 | name: string; 14 | 15 | @ManyToMany( 16 | ()=> Movie, 17 | movie => movie.genres, 18 | ) 19 | movies: Movie[]; 20 | } 21 | -------------------------------------------------------------------------------- /src/genre/genre.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GenreController } from './genre.controller'; 3 | import { GenreService } from './genre.service'; 4 | import { getRepositoryToken } from '@nestjs/typeorm'; 5 | import { Genre } from './entity/genre.entity'; 6 | import { CreateGenreDto } from './dto/create-genre.dto'; 7 | 8 | const mockGenreService = { 9 | create: jest.fn(), 10 | findAll: jest.fn(), 11 | findOne: jest.fn(), 12 | update: jest.fn(), 13 | remove: jest.fn(), 14 | }; 15 | 16 | describe('GenreController', () => { 17 | let controller: GenreController; 18 | let service: GenreService; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | controllers: [GenreController], 23 | providers: [ 24 | { 25 | provide: GenreService, 26 | useValue: mockGenreService, 27 | } 28 | ], 29 | }).compile(); 30 | 31 | controller = module.get(GenreController); 32 | service = module.get(GenreService); 33 | }); 34 | 35 | afterEach(()=>{ 36 | jest.clearAllMocks(); 37 | }) 38 | 39 | it('should be defined', () => { 40 | expect(controller).toBeDefined(); 41 | }); 42 | 43 | describe('create', ()=>{ 44 | it('should call genreService.create with correct parameter', async ()=>{ 45 | const createGenreDto = { 46 | name: 'Fantasy', 47 | }; 48 | const result = {id: 1, ...createGenreDto}; 49 | 50 | jest.spyOn(service, 'create').mockResolvedValue(result as CreateGenreDto & Genre); 51 | 52 | expect(controller.create(createGenreDto)).resolves.toEqual(result); 53 | expect(service.create).toHaveBeenCalledWith(createGenreDto); 54 | }); 55 | }); 56 | 57 | describe('findAll', ()=>{ 58 | it('should call genreService.findAll and return an array of genres', async ()=>{ 59 | const result = [ 60 | { 61 | id: 1, 62 | name: 'Fantasy', 63 | }, 64 | ]; 65 | 66 | jest.spyOn(service, 'findAll').mockResolvedValue(result as Genre[]); 67 | 68 | expect(controller.findAll()).resolves.toEqual(result); 69 | expect(service.findAll).toHaveBeenCalled(); 70 | }) 71 | }); 72 | 73 | describe('findOne', ()=>{ 74 | it('should call genreService.findOne with correct id and return the genre', async ()=>{ 75 | const id = 1; 76 | const result = { 77 | id: 1, 78 | name: 'Fantasy', 79 | }; 80 | 81 | jest.spyOn(service, 'findOne').mockResolvedValue(result as Genre); 82 | 83 | expect(controller.findOne(id)).resolves.toEqual(result); 84 | expect(service.findOne).toHaveBeenCalledWith(id); 85 | }); 86 | }); 87 | 88 | describe('update', ()=>{ 89 | it('should call genreService.update with correct parameters and return updated genre', async()=>{ 90 | const id = 1; 91 | const updateGenreDto = { 92 | name: 'UpdatedFantasy', 93 | }; 94 | const result = {id: 1, ...updateGenreDto}; 95 | 96 | jest.spyOn(service, 'update').mockResolvedValue(result as Genre); 97 | 98 | expect(controller.update(id, updateGenreDto)).resolves.toEqual(result); 99 | expect(service.update).toHaveBeenCalledWith(id, updateGenreDto); 100 | }); 101 | }); 102 | 103 | describe('remove', ()=>{ 104 | it('should call genreService.remove with correct id and return id of the removed genre', async ()=>{ 105 | const id = 1; 106 | 107 | jest.spyOn(service, 'remove').mockResolvedValue(id); 108 | 109 | expect(controller.remove(id)).resolves.toBe(id); 110 | expect(service.remove).toHaveBeenCalledWith(id); 111 | }) 112 | }) 113 | }); 114 | -------------------------------------------------------------------------------- /src/genre/genre.controller.ts: -------------------------------------------------------------------------------- 1 | import { Type, Controller, Get, Post, Body, Patch, Param, Delete, UseInterceptors, ClassSerializerInterceptor, ParseIntPipe, PlainLiteralObject, ClassSerializerContextOptions } from '@nestjs/common'; 2 | import { GenreService } from './genre.service'; 3 | import { CreateGenreDto } from './dto/create-genre.dto'; 4 | import { UpdateGenreDto } from './dto/update-genre.dto'; 5 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 6 | import { serialize } from 'v8'; 7 | import { Document } from 'mongoose'; 8 | import { plainToInstance } from 'class-transformer'; 9 | import { Genre } from './schema/genre.schema'; 10 | 11 | @Controller('genre') 12 | @ApiBearerAuth() 13 | @ApiTags('genre') 14 | // @UseInterceptors(ClassSerializerInterceptor) 15 | export class GenreController { 16 | constructor(private readonly genreService: GenreService) {} 17 | 18 | @Post() 19 | create(@Body() createGenreDto: CreateGenreDto) { 20 | return this.genreService.create(createGenreDto); 21 | } 22 | 23 | @Get() 24 | findAll() { 25 | return this.genreService.findAll(); 26 | } 27 | 28 | @Get(':id') 29 | findOne(@Param('id') id: string) { 30 | return this.genreService.findOne(id); 31 | } 32 | 33 | @Patch(':id') 34 | update(@Param('id') id: string, @Body() updateGenreDto: UpdateGenreDto) { 35 | return this.genreService.update(id, updateGenreDto); 36 | } 37 | 38 | @Delete(':id') 39 | remove(@Param('id') id: string) { 40 | return this.genreService.remove(id); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/genre/genre.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GenreService } from './genre.service'; 3 | import { GenreController } from './genre.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | // import { Genre } from './entity/genre.entity'; 6 | import { CommonModule } from 'src/common/common.module'; 7 | import { Genre, GenreSchema } from './schema/genre.schema'; 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | 10 | 11 | @Module({ 12 | imports: [ 13 | // TypeOrmModule.forFeature([ 14 | // Genre, 15 | // ]), 16 | MongooseModule.forFeature([ 17 | { 18 | name: Genre.name, 19 | schema: GenreSchema, 20 | } 21 | ]), 22 | CommonModule, 23 | ], 24 | controllers: [GenreController], 25 | providers: [GenreService], 26 | }) 27 | export class GenreModule { } 28 | -------------------------------------------------------------------------------- /src/genre/genre.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GenreService } from './genre.service'; 3 | import { Repository } from 'typeorm'; 4 | import { Genre } from './entity/genre.entity'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { after } from 'node:test'; 7 | import { NotFoundException } from '@nestjs/common'; 8 | 9 | const mockGenreRepository = { 10 | save: jest.fn(), 11 | find: jest.fn(), 12 | findOne: jest.fn(), 13 | update: jest.fn(), 14 | delete: jest.fn(), 15 | }; 16 | 17 | describe('GenreService', () => { 18 | let service: GenreService; 19 | let repository: Repository; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | GenreService, 25 | { 26 | provide: getRepositoryToken(Genre), 27 | useValue: mockGenreRepository, 28 | } 29 | ], 30 | }).compile(); 31 | 32 | service = module.get(GenreService); 33 | repository = module.get>(getRepositoryToken(Genre)); 34 | }); 35 | 36 | afterAll(() => { 37 | jest.clearAllMocks(); 38 | }) 39 | 40 | it('should be defined', () => { 41 | expect(service).toBeDefined(); 42 | }); 43 | 44 | describe('create', () => { 45 | it('should create a genre successfully', async () => { 46 | const createGenreDto = { name: 'Fantasy' }; 47 | const savedGenre = { id: 1, ...createGenreDto }; 48 | 49 | jest.spyOn(repository, 'save').mockResolvedValue(savedGenre as Genre); 50 | 51 | const result = await service.create(createGenreDto); 52 | 53 | expect(repository.save).toHaveBeenCalledWith(createGenreDto); 54 | expect(result).toEqual(savedGenre); 55 | }); 56 | }); 57 | 58 | describe('findAll', () => { 59 | it('should return an array of genres', async () => { 60 | const genres = [ 61 | { 62 | id: 1, 63 | name: 'Fantasy', 64 | }, 65 | ]; 66 | jest.spyOn(repository, 'find').mockResolvedValue(genres as Genre[]); 67 | 68 | const result = await service.findAll(); 69 | 70 | expect(repository.find).toHaveBeenCalled(); 71 | expect(result).toEqual(genres); 72 | }); 73 | }); 74 | 75 | describe('findOne', () => { 76 | it('should return a genre if found', async () => { 77 | const genre = { id: 1, name: 'Fantasy' }; 78 | 79 | jest.spyOn(repository, 'findOne').mockResolvedValue(genre as Genre); 80 | 81 | const result = await service.findOne(genre.id); 82 | 83 | expect(repository.findOne).toHaveBeenCalledWith({ 84 | where: { 85 | id: genre.id, 86 | }, 87 | }); 88 | 89 | expect(result).toEqual(genre); 90 | }); 91 | 92 | it('should throw a NotFoundException if genre is not found', async () => { 93 | jest.spyOn(repository, 'findOne').mockResolvedValue(null); 94 | 95 | await expect(service.findOne(1)).rejects.toThrow(NotFoundException); 96 | }) 97 | }); 98 | 99 | describe('update', () => { 100 | it('should update and return the genre if it exists', async () => { 101 | const updateGenreDto = { 102 | name: 'Updated Fanatasy', 103 | }; 104 | const existingGenre = { 105 | id: 1, 106 | name: 'Fantasy', 107 | }; 108 | const updatedGenre = { 109 | id: 1, 110 | ...updateGenreDto, 111 | }; 112 | 113 | jest.spyOn(repository, 'findOne') 114 | .mockResolvedValueOnce(existingGenre as Genre) 115 | .mockResolvedValueOnce(updatedGenre as Genre); 116 | 117 | const result = await service.update(1, updateGenreDto); 118 | 119 | expect(repository.findOne).toHaveBeenCalledWith({ 120 | where:{ 121 | id: 1, 122 | }, 123 | }); 124 | expect(repository.update).toHaveBeenCalledWith({ 125 | id: 1 126 | }, updateGenreDto); 127 | expect(result).toEqual(updatedGenre); 128 | }); 129 | 130 | it('should throw a NotFoundException if genre to update does not exist', async ()=>{ 131 | jest.spyOn(repository, 'findOne').mockResolvedValue(null); 132 | 133 | await expect(service.update(1, {name: 'Updated Fantasy'})).rejects.toThrow(NotFoundException); 134 | }); 135 | }); 136 | 137 | describe('remove', ()=>{ 138 | it('should delete a genre and return the id', async ()=>{ 139 | const genre = { 140 | id: 1, 141 | name: 'Fantasy', 142 | }; 143 | 144 | jest.spyOn(repository, 'findOne').mockResolvedValue(genre as Genre); 145 | 146 | const result = await service.remove(1); 147 | 148 | expect(repository.findOne).toHaveBeenCalledWith({ 149 | where: { 150 | id: 1, 151 | } 152 | }); 153 | expect(repository.delete).toHaveBeenCalledWith(1); 154 | expect(result).toBe(1); 155 | }); 156 | 157 | it('should throw a NotFoundException if genre to delete does not exist', async ()=>{ 158 | jest.spyOn(repository, 'findOne').mockResolvedValue(null); 159 | 160 | await expect(service.remove(1)).rejects.toThrow(NotFoundException); 161 | }) 162 | }); 163 | 164 | }); 165 | -------------------------------------------------------------------------------- /src/genre/genre.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { CreateGenreDto } from './dto/create-genre.dto'; 3 | import { UpdateGenreDto } from './dto/update-genre.dto'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | // import { Genre } from './entity/genre.entity'; 6 | import { Repository } from 'typeorm'; 7 | import { NotFoundError } from 'rxjs'; 8 | import { PrismaService } from 'src/common/prisma.service'; 9 | import { InjectModel } from '@nestjs/mongoose'; 10 | import { Genre } from './schema/genre.schema'; 11 | import { Model } from 'mongoose'; 12 | 13 | @Injectable() 14 | export class GenreService { 15 | constructor( 16 | // @InjectRepository(Genre) 17 | // private readonly genreRepository: Repository, 18 | // private readonly prisma: PrismaService, 19 | @InjectModel(Genre.name) 20 | private readonly genreModel: Model, 21 | ) { } 22 | 23 | async create(createGenreDto: CreateGenreDto) { 24 | // const genre = await this.genreRepository.findOne({ 25 | // where:{ 26 | // name: createGenreDto.name, 27 | // } 28 | // }); 29 | 30 | // if(genre){ 31 | // throw new NotFoundException('이미 존재하는 장르입니다!'); 32 | // } 33 | 34 | // return { 35 | // ...result.toObject(), 36 | // _id: result._id.toString(), 37 | // }; 38 | 39 | return this.genreModel.create(createGenreDto); 40 | 41 | // return this.prisma.genre.create({ 42 | // data: createGenreDto, 43 | // }) 44 | // return this.genreRepository.save(createGenreDto); 45 | } 46 | 47 | findAll() { 48 | return this.genreModel.find().exec(); 49 | // return this.prisma.genre.findMany(); 50 | // return this.genreRepository.find(); 51 | } 52 | 53 | async findOne(id: string) { 54 | const genre = await this.genreModel.findById(id).exec(); 55 | // const genre = await this.prisma.genre.findUnique({ 56 | // where: { 57 | // id 58 | // } 59 | // }); 60 | // const genre = await this.genreRepository.findOne({ 61 | // where:{ 62 | // id, 63 | // } 64 | // }); 65 | 66 | if (!genre) { 67 | throw new NotFoundException('존재하지 않는 장르입니다!'); 68 | } 69 | 70 | return genre; 71 | } 72 | 73 | async update(id: string, updateGenreDto: UpdateGenreDto) { 74 | const genre = await this.genreModel.findById(id).exec(); 75 | // const genre = await this.prisma.genre.findUnique({ 76 | // where: { 77 | // id, 78 | // } 79 | // }) 80 | // const genre = await this.genreRepository.findOne({ 81 | // where:{ 82 | // id, 83 | // } 84 | // }); 85 | 86 | if (!genre) { 87 | throw new NotFoundException('존재하지 않는 장르입니다!'); 88 | } 89 | 90 | await this.genreModel.findByIdAndUpdate(id, updateGenreDto).exec(); 91 | // await this.prisma.genre.update({ 92 | // where: { 93 | // id, 94 | // }, 95 | // data: { 96 | // ...updateGenreDto, 97 | // } 98 | // }) 99 | // await this.genreRepository.update({ 100 | // id, 101 | // }, { 102 | // ...updateGenreDto, 103 | // }); 104 | 105 | const newGenre = await this.genreModel.findById(id).exec(); 106 | // const newGenre = await this.prisma.genre.findUnique({ 107 | // where: { 108 | // id, 109 | // } 110 | // }) 111 | // const newGenre = await this.genreRepository.findOne({ 112 | // where: { 113 | // id, 114 | // } 115 | // }); 116 | 117 | return newGenre; 118 | } 119 | 120 | async remove(id: string) { 121 | const genre = await this.genreModel.findById(id); 122 | // const genre = await this.prisma.genre.findUnique({ 123 | // where: { 124 | // id, 125 | // } 126 | // }) 127 | // const genre = await this.genreRepository.findOne({ 128 | // where: { 129 | // id, 130 | // } 131 | // }); 132 | 133 | if (!genre) { 134 | throw new NotFoundException('존재하지 않는 장르입니다!'); 135 | } 136 | 137 | await this.genreModel.findByIdAndDelete(id); 138 | // await this.prisma.genre.delete({ 139 | // where: { id } 140 | // }) 141 | // await this.genreRepository.delete(id); 142 | 143 | return id; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/genre/schema/genre.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { Movie } from "src/movie/schema/movie.schema"; 3 | import { Document, Types } from 'mongoose'; 4 | import { Exclude } from "class-transformer"; 5 | 6 | @Schema({ 7 | timestamps: true, 8 | }) 9 | export class Genre extends Document { 10 | @Prop({ 11 | required: true, 12 | unique: true, 13 | }) 14 | name: string; 15 | 16 | @Prop({ 17 | type: [{ 18 | type: Types.ObjectId, 19 | ref: 'Movie', 20 | }], 21 | }) 22 | movies: Movie[]; 23 | } 24 | 25 | export const GenreSchema = SchemaFactory.createForClass(Genre); -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe, VersioningType } from '@nestjs/common'; 4 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | import * as ffmpeg from '@ffmpeg-installer/ffmpeg'; 7 | import * as ffmpegFluent from 'fluent-ffmpeg'; 8 | import * as ffprobe from 'ffprobe-static'; 9 | import * as session from 'express-session'; 10 | 11 | ffmpegFluent.setFfmpegPath(ffmpeg.path); 12 | ffmpegFluent.setFfprobePath(ffprobe.path); 13 | 14 | async function bootstrap() { 15 | const app = await NestFactory.create(AppModule, { 16 | logger: ['verbose'], 17 | }); 18 | 19 | const config = new DocumentBuilder() 20 | .setTitle('코드팩토리 넷플릭스') 21 | .setDescription('코드팩토리 NestJS 강의!') 22 | .setVersion('1.0') 23 | .addBasicAuth() 24 | .addBearerAuth() 25 | .build(); 26 | 27 | const document = SwaggerModule.createDocument(app, config); 28 | 29 | SwaggerModule.setup('doc', app, document, { 30 | swaggerOptions:{ 31 | persistAuthorization: true, 32 | } 33 | }) 34 | 35 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)) 36 | 37 | app.useGlobalPipes(new ValidationPipe({ 38 | whitelist: true, 39 | forbidNonWhitelisted: true, 40 | transformOptions:{ 41 | enableImplicitConversion: true, 42 | } 43 | })); 44 | 45 | app.use( 46 | session({ 47 | secret: 'secret', 48 | }) 49 | ) 50 | 51 | await app.listen(process.env.PORT || 3000); 52 | } 53 | bootstrap(); 54 | -------------------------------------------------------------------------------- /src/movie/dto/create-movie.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { Type } from "class-transformer"; 3 | import { ArrayNotEmpty, IsArray, IsNotEmpty, IsNumber, IsString } from "class-validator"; 4 | 5 | export class CreateMovieDto { 6 | @IsNotEmpty() 7 | @IsString() 8 | @ApiProperty({ 9 | description: '영화 제목', 10 | example: '겨울왕국', 11 | }) 12 | title: string; 13 | 14 | @IsNotEmpty() 15 | @IsString() 16 | @ApiProperty({ 17 | description: '영화 설명', 18 | example: '3시간 훅가쥬?' 19 | }) 20 | detail: string; 21 | 22 | @IsNotEmpty() 23 | @IsString() 24 | @ApiProperty({ 25 | description: '감독 객체 ID', 26 | example: 1, 27 | }) 28 | directorId: string; 29 | 30 | @ArrayNotEmpty() 31 | @IsString({ 32 | each: true, 33 | }) 34 | @Type(() => String) 35 | @ApiProperty({ 36 | description: '장르 IDs', 37 | example: [1,2,3], 38 | }) 39 | genreIds: string[]; 40 | 41 | @IsString() 42 | @ApiProperty({ 43 | description: '영화 파일 이름', 44 | example: 'aaa-bbb-ccc-ddd.jpg' 45 | }) 46 | movieFileName: string; 47 | } -------------------------------------------------------------------------------- /src/movie/dto/get-movies.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { IsInt, IsOptional, IsString } from "class-validator"; 3 | import { CursorPaginationDto } from "src/common/dto/cursor-pagination.dto"; 4 | import { PagePaginationDto } from "src/common/dto/page-pagination.dto"; 5 | 6 | export class GetMoviesDto extends CursorPaginationDto{ 7 | @IsString() 8 | @IsOptional() 9 | @ApiProperty({ 10 | description: '영화의 제목', 11 | example: '프로메테우스', 12 | }) 13 | title?: string; 14 | } -------------------------------------------------------------------------------- /src/movie/dto/update-movie.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArrayNotEmpty, Contains, Equals, IsAlphanumeric, IsArray, IsBoolean, IsCreditCard, IsDate, IsDateString, IsDefined, IsDivisibleBy, IsEmpty, IsEnum, IsHexColor, IsIn, IsInt, IsLatLong, IsNegative, IsNotEmpty, IsNotIn, IsNumber, IsOptional, IsPositive, IsString, IsUUID, Max, MaxLength, Min, MinLength, NotContains, NotEquals, registerDecorator, Validate, ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; 2 | import { CreateMovieDto } from "./create-movie.dto"; 3 | import { PartialType } from "@nestjs/swagger"; 4 | 5 | export class UpdateMovieDto extends PartialType(CreateMovieDto){} -------------------------------------------------------------------------------- /src/movie/entity/movie-detail.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm"; 2 | import { Movie } from "./movie.entity"; 3 | 4 | @Entity() 5 | export class MovieDetail{ 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | detail: string; 11 | 12 | @OneToOne( 13 | () => Movie, 14 | movie => movie.id, 15 | ) 16 | movie: Movie; 17 | } -------------------------------------------------------------------------------- /src/movie/entity/movie-user-like.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm"; 2 | import { Movie } from "./movie.entity"; 3 | import { User } from "src/user/entity/user.entity"; 4 | 5 | @Entity() 6 | export class MovieUserLike { 7 | 8 | @PrimaryColumn({ 9 | name: 'movieId', 10 | type: 'int8', 11 | }) 12 | @ManyToOne( 13 | ()=> Movie, 14 | (movie) => movie.likedUsers, 15 | { 16 | onDelete: 'CASCADE', 17 | } 18 | ) 19 | movie: Movie; 20 | 21 | @PrimaryColumn({ 22 | name: 'userId', 23 | type: 'int8' 24 | }) 25 | @ManyToOne( 26 | ()=> User, 27 | (user) => user.likedMovies, 28 | { 29 | onDelete: 'CASCADE', 30 | } 31 | ) 32 | user: User; 33 | 34 | @Column() 35 | isLike: boolean; 36 | } -------------------------------------------------------------------------------- /src/movie/entity/movie.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseExceptionFilter } from "@nestjs/core"; 2 | import { Exclude, Expose, Transform } from "class-transformer"; 3 | import { ChildEntity, Column, CreateDateColumn, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn, TableInheritance, UpdateDateColumn, VersionColumn } from "typeorm"; 4 | import { BaseTable } from "../../common/entity/base-table.entity"; 5 | import { MovieDetail } from "./movie-detail.entity"; 6 | import { Director } from "src/director/entity/director.entity"; 7 | import { Genre } from "src/genre/entity/genre.entity"; 8 | import { MovieFilePipe } from "../pipe/movie-file.pipe"; 9 | import { User } from "src/user/entity/user.entity"; 10 | import { MovieUserLike } from "./movie-user-like.entity"; 11 | 12 | /// ManyToOne Director -> 감독은 여러개의 영화를 만들 수 있음 13 | /// OneToOne MovieDetail -> 영화는 하나의 상세 내용을 갖을 수 있음 14 | /// ManyToMany Genre -> 영화는 여러개의 장르를 갖을 수 있고 장르는 여러개의 영화에 속할 수 있음. 15 | @Entity() 16 | export class Movie extends BaseTable { 17 | @PrimaryGeneratedColumn() 18 | id: number; 19 | 20 | @ManyToOne( 21 | () => User, 22 | (user) => user.createdMovies, 23 | ) 24 | creator: User; 25 | 26 | @Column({ 27 | unique: true, 28 | }) 29 | title: string; 30 | 31 | @ManyToMany( 32 | () => Genre, 33 | genre => genre.movies, 34 | ) 35 | @JoinTable() 36 | genres: Genre[]; 37 | 38 | @Column({ 39 | default: 0, 40 | }) 41 | likeCount: number; 42 | 43 | @Column({ 44 | default: 0, 45 | }) 46 | dislikeCount: number; 47 | 48 | @OneToOne( 49 | () => MovieDetail, 50 | movieDetail => movieDetail.id, 51 | { 52 | cascade: true, 53 | nullable: false, 54 | } 55 | ) 56 | @JoinColumn() 57 | detail: MovieDetail; 58 | 59 | @Column() 60 | @Transform(({ value }) => process.env.ENV === 'prod' ? `http://${process.env.BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${value}` : `http://localhost:3000/${value}`) 61 | movieFilePath: string; 62 | 63 | @ManyToOne( 64 | () => Director, 65 | director => director.id, 66 | { 67 | cascade: true, 68 | nullable: false, 69 | } 70 | ) 71 | director: Director; 72 | 73 | @OneToMany( 74 | ()=> MovieUserLike, 75 | (mul) => mul.movie, 76 | ) 77 | likedUsers: MovieUserLike[]; 78 | } -------------------------------------------------------------------------------- /src/movie/movie.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MovieController } from './movie.controller'; 3 | import { MovieService } from './movie.service'; 4 | import { TestBed } from '@automock/jest'; 5 | import { Movie } from './entity/movie.entity'; 6 | import { CreateMovieDto } from './dto/create-movie.dto'; 7 | import { QueryRunner } from 'typeorm'; 8 | import { UpdateMovieDto } from './dto/update-movie.dto'; 9 | 10 | describe('MovieController', () => { 11 | let movieController: MovieController; 12 | let movieService: jest.Mocked; 13 | 14 | beforeEach(async () => { 15 | const { unit, unitRef } = TestBed.create(MovieController).compile(); 16 | 17 | movieController = unit; 18 | movieService = unitRef.get(MovieService); 19 | }); 20 | 21 | it('should be defined', () => { 22 | expect(movieController).toBeDefined(); 23 | }); 24 | 25 | describe('getMovies', ()=>{ 26 | it('should call movieService.findAll with the correct parameters', async ()=>{ 27 | const dto = {page: 1, limit: 10}; 28 | const userId = 1; 29 | const movies = [{id: 1}, {id:2}] 30 | 31 | jest.spyOn(movieService, 'findAll').mockResolvedValue(movies as any); 32 | 33 | const result = await movieController.getMovies(dto as any, userId); 34 | 35 | expect(movieService.findAll).toHaveBeenCalledWith(dto, userId); 36 | expect(result).toEqual(movies); 37 | }); 38 | }); 39 | 40 | describe('recent', ()=>{ 41 | it('should call movieService.findRecent', async ()=>{ 42 | const dto = {page: 1, limit: 10}; 43 | const userId = 1; 44 | const movies = [{id: 1}, {id:2}] 45 | 46 | await movieController.getMoviesRecent(); 47 | 48 | expect(movieService.findRecent).toHaveBeenCalled(); 49 | }); 50 | }); 51 | 52 | describe('getMovie', ()=>{ 53 | it('should call movieService.findOne with the correct id', async ()=>{ 54 | const id = 1; 55 | await movieController.getMovie(1); 56 | 57 | expect(movieService.findOne).toHaveBeenCalledWith(1); 58 | }) 59 | }) 60 | 61 | describe('postMovie', ()=>{ 62 | it('should call movieService.create with the correct parameters', async ()=>{ 63 | const body = {title: 'Test Movie'}; 64 | const userId = 1; 65 | const queryRunner = {}; 66 | 67 | await movieController.postMovie(body as CreateMovieDto, queryRunner as QueryRunner, userId); 68 | 69 | expect(movieService.create).toHaveBeenCalledWith(body, userId, queryRunner); 70 | }) 71 | }) 72 | 73 | describe('patchMovie', ()=>{ 74 | it('should call movieService.update with the correct parameters', async()=>{ 75 | const id = 1; 76 | const body: UpdateMovieDto = {title: 'Updated Movie'}; 77 | 78 | await movieController.patchMovie(id, body); 79 | 80 | expect(movieService.update).toHaveBeenCalledWith(id, body); 81 | }) 82 | }); 83 | 84 | describe('deleteMovie', ()=>{ 85 | it('should call movieService.remove with the correct id', async ()=>{ 86 | const id = 1; 87 | await movieController.deleteMovie(id); 88 | expect(movieService.remove).toHaveBeenCalledWith(id); 89 | }) 90 | }) 91 | 92 | describe('createMovieLike', ()=>{ 93 | it('should call movieService.toggleMovieLike with the correct parameters', async ()=>{ 94 | const movieId = 1; 95 | const userId = 2; 96 | 97 | await movieController.createMovieLike(movieId, userId); 98 | expect(movieService.toggleMovieLike).toHaveBeenCalledWith(movieId, userId, true); 99 | }) 100 | }) 101 | 102 | describe('createMovieDislike', ()=>{ 103 | it('should call movieService.toggleMovieDislike with the correct parameters', async ()=>{ 104 | const movieId = 1; 105 | const userId = 2; 106 | 107 | await movieController.createMovieDislike(movieId, userId); 108 | expect(movieService.toggleMovieLike).toHaveBeenCalledWith(movieId, userId, false); 109 | }) 110 | }) 111 | }); 112 | -------------------------------------------------------------------------------- /src/movie/movie.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Request, Get, Post, Body, Patch, Param, Delete, Query, UseInterceptors, ClassSerializerInterceptor, UsePipes, ValidationPipe, ParseIntPipe, BadRequestException, NotFoundException, ParseFloatPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe, ParseEnumPipe, DefaultValuePipe, UseGuards, UploadedFile, UploadedFiles, Version, VERSION_NEUTRAL, Req } from '@nestjs/common'; 2 | import { MovieService } from './movie.service'; 3 | import { CreateMovieDto } from './dto/create-movie.dto'; 4 | import { UpdateMovieDto } from './dto/update-movie.dto'; 5 | import { MovieTitleValidationPipe } from './pipe/movie-title-validation.pipe'; 6 | import { AuthGuard } from 'src/auth/guard/auth.guard'; 7 | import { Public } from 'src/auth/decorator/public.decorator'; 8 | import { RBAC } from 'src/auth/decorator/rbac.decorator'; 9 | import { GetMoviesDto } from './dto/get-movies.dto'; 10 | import { CacheInterceptor } from 'src/common/interceptor/cache.interceptor'; 11 | import { TransactionInterceptor } from 'src/common/interceptor/transaction.interceptor'; 12 | import { FileFieldsInterceptor, FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; 13 | import { MovieFilePipe } from './pipe/movie-file.pipe'; 14 | import { UserId } from 'src/user/decorator/user-id.decorator'; 15 | import { QueryRunner } from 'src/common/decorator/query-runner.decorator'; 16 | import { QueryRunner as QR } from 'typeorm'; 17 | import { CacheKey, CacheTTL, CacheInterceptor as CI } from '@nestjs/cache-manager'; 18 | import { Throttle } from 'src/common/decorator/throttle.decorator'; 19 | import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 20 | import { Movie } from './entity/movie.entity'; 21 | import { Role } from '@prisma/client'; 22 | 23 | @Controller('movie') 24 | @ApiBearerAuth() 25 | @ApiTags('movie') 26 | // @UseInterceptors(ClassSerializerInterceptor) 27 | export class MovieController { 28 | constructor(private readonly movieService: MovieService) { } 29 | 30 | @Get() 31 | @Public() 32 | @Throttle({ 33 | count: 5, 34 | unit: 'minute', 35 | }) 36 | @ApiOperation({ 37 | description: '[Movie]를 Pagination 하는 API' 38 | }) 39 | @ApiResponse({ 40 | status: 200, 41 | description: '성공적으로 API Pagination을 실행 했을때!', 42 | }) 43 | @ApiResponse({ 44 | status: 400, 45 | description: 'Pagination 데이터를 잘못 입력 했을때', 46 | }) 47 | getMovies( 48 | @Query() dto: GetMoviesDto, 49 | @UserId() userId?: number, 50 | ) { 51 | /// title 쿼리의 타입이 string 타입인지? 52 | return this.movieService.findAll(dto, userId); 53 | } 54 | 55 | /// /movie/recent?sdfjiv 56 | @Get('recent') 57 | @UseInterceptors(CI) 58 | @CacheKey('getMoviesRecent') 59 | @CacheTTL(1000) 60 | getMoviesRecent() { 61 | return this.movieService.findRecent(); 62 | } 63 | 64 | /// /movie/askdjfoixcv 65 | @Get(':id') 66 | @Public() 67 | getMovie( 68 | @Param('id') id: string, 69 | @Req() request: any, 70 | ) { 71 | const session = request.session; 72 | 73 | const movieCount = session.movieCount ?? {}; 74 | 75 | request.session.movieCount = { 76 | ...movieCount, 77 | [id]: movieCount[id] ? movieCount[id] + 1 : 1, 78 | } 79 | 80 | return this.movieService.findOne(id); 81 | } 82 | 83 | @Post() 84 | @RBAC(Role.admin) 85 | // @UseInterceptors(TransactionInterceptor) 86 | postMovie( 87 | @Body() body: CreateMovieDto, 88 | // @QueryRunner() queryRunner: QR, 89 | @UserId() userId: number, 90 | ) { 91 | return this.movieService.create( 92 | body, 93 | userId, 94 | // queryRunner, 95 | ); 96 | } 97 | 98 | @Patch(':id') 99 | @RBAC(Role.admin) 100 | patchMovie( 101 | @Param('id') id: string, 102 | @Body() body: UpdateMovieDto, 103 | ) { 104 | return this.movieService.update( 105 | id, 106 | body, 107 | ); 108 | } 109 | 110 | @Delete(':id') 111 | @RBAC(Role.admin) 112 | deleteMovie( 113 | @Param('id') id: string, 114 | ) { 115 | return this.movieService.remove( 116 | id, 117 | ); 118 | } 119 | 120 | /** 121 | * [Like] [Dislike] 122 | * 123 | * 아무것도 누르지 않은 상태 124 | * Like & Dislike 모두 버튼 꺼져있음 125 | * 126 | * Like 버튼 누르면 127 | * Like 버튼 불 켜짐 128 | * 129 | * Like 버튼 다시 누르면 130 | * Like 버튼 불 꺼짐 131 | * 132 | * Dislike 버튼 누르면 133 | * Dislike 버튼 불 켜짐 134 | * 135 | * Dislike 버튼 다시 누르면 136 | * Dislike 버튼 불 꺼짐 137 | * 138 | * Like 버튼 누름 139 | * Like 버튼 불 켜짐 140 | * 141 | * Dislike 버튼 누름 142 | * Like 버튼 불 꺼지고 Dislike 버튼 불 켜짐 143 | */ 144 | @Post(':id/like') 145 | createMovieLike( 146 | @Param('id') movieId: string, 147 | @UserId() userId: string, 148 | ) { 149 | return this.movieService.toggleMovieLike(movieId, userId, true); 150 | } 151 | 152 | @Post(':id/dislike') 153 | createMovieDislike( 154 | @Param('id') movieId: string, 155 | @UserId() userId: string, 156 | ) { 157 | return this.movieService.toggleMovieLike(movieId, userId, false); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/movie/movie.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from 'src/app.module'; 5 | import { Role, User } from 'src/user/entity/user.entity'; 6 | import { Director } from 'src/director/entity/director.entity'; 7 | import { Movie } from './entity/movie.entity'; 8 | import { Genre } from 'src/genre/entity/genre.entity'; 9 | import { DataSource } from 'typeorm'; 10 | import { MovieDetail } from './entity/movie-detail.entity'; 11 | import { MovieUserLike } from './entity/movie-user-like.entity'; 12 | import { AuthService } from 'src/auth/auth.service'; 13 | 14 | describe('MovieController (e2e)', () => { 15 | let app: INestApplication; 16 | let dataSource: DataSource; 17 | 18 | let users: User[]; 19 | let directors: Director[]; 20 | let movies: Movie[]; 21 | let genres: Genre[]; 22 | 23 | let token: string; 24 | 25 | beforeAll(async () => { 26 | const moduleFixture: TestingModule = await Test.createTestingModule({ 27 | imports: [AppModule], 28 | }).compile(); 29 | 30 | app = moduleFixture.createNestApplication(); 31 | app.useGlobalPipes(new ValidationPipe({ 32 | whitelist: true, 33 | forbidNonWhitelisted: true, 34 | transformOptions: { 35 | enableImplicitConversion: true, 36 | } 37 | })) 38 | await app.init(); 39 | 40 | dataSource = app.get(DataSource); 41 | 42 | const movieUserLikeRepository = dataSource.getRepository(MovieUserLike); 43 | const movieRepository = dataSource.getRepository(Movie); 44 | const movieDetailRepository = dataSource.getRepository(MovieDetail); 45 | const userRepository = dataSource.getRepository(User); 46 | const directorRepository = dataSource.getRepository(Director); 47 | const genreRepository = dataSource.getRepository(Genre); 48 | 49 | await movieUserLikeRepository.delete({}); 50 | await movieRepository.delete({}); 51 | await genreRepository.delete({}); 52 | await directorRepository.delete({}); 53 | await userRepository.delete({}); 54 | await movieDetailRepository.delete({}); 55 | 56 | users = [1, 2].map( 57 | (x) => userRepository.create({ 58 | id: x, 59 | email: `${x}@test.com`, 60 | password: `123123`, 61 | }) 62 | ); 63 | 64 | await userRepository.save(users); 65 | 66 | directors = [1, 2].map( 67 | x => directorRepository.create({ 68 | id: x, 69 | dob: new Date('1992-11-23'), 70 | nationality: 'South Korea', 71 | name: `Director Name ${x}`, 72 | }) 73 | ); 74 | 75 | await directorRepository.save(directors); 76 | 77 | genres = [1, 2].map( 78 | x => genreRepository.create({ 79 | id: x, 80 | name: `Genre ${x}`, 81 | }) 82 | ); 83 | 84 | await genreRepository.save(genres); 85 | 86 | movies = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map( 87 | x => movieRepository.create({ 88 | id: x, 89 | title: `Movie ${x}`, 90 | creator: users[0], 91 | genres: genres, 92 | likeCount: 0, 93 | dislikeCount: 0, 94 | detail: movieDetailRepository.create({ 95 | detail: `Movie Detail ${x}`, 96 | }), 97 | movieFilePath: 'movies/movie1.mp4', 98 | director: directors[0], 99 | createdAt: new Date(`2023-9-${x}`), 100 | }) 101 | ); 102 | 103 | await movieRepository.save(movies); 104 | 105 | let authService = moduleFixture.get(AuthService); 106 | token = await authService.issueToken({ id: users[0].id, role: Role.admin }, false); 107 | }); 108 | 109 | afterAll(async () => { 110 | await new Promise(resolve => setTimeout(resolve, 500)); 111 | await dataSource.destroy(); 112 | await app.close(); 113 | }) 114 | 115 | describe('[GET /movie]', () => { 116 | it('should get all movies', async () => { 117 | const { body, statusCode, error } = await request(app.getHttpServer()) 118 | .get('/movie') 119 | 120 | expect(statusCode).toBe(200); 121 | expect(body).toHaveProperty('data'); 122 | expect(body).toHaveProperty('nextCursor'); 123 | expect(body).toHaveProperty('count'); 124 | 125 | expect(body.data).toHaveLength(5); 126 | }); 127 | }); 128 | 129 | describe('[GET /movie/recent]', () => { 130 | it('should get recent movies', async () => { 131 | const { body, statusCode } = await await request(app.getHttpServer()) 132 | .get('/movie/recent') 133 | .set('authorization', `Bearer ${token}`); 134 | 135 | expect(statusCode).toBe(200); 136 | expect(body).toHaveLength(10); 137 | }); 138 | }); 139 | 140 | describe('[GET /movie/{id}]', () => { 141 | it('should get movie by id', async () => { 142 | const movieId = movies[0].id; 143 | 144 | const { body, statusCode } = await await request(app.getHttpServer()) 145 | .get(`/movie/${movieId}`) 146 | .set('authorization', `Bearer ${token}`); 147 | 148 | expect(statusCode).toBe(200); 149 | expect(body.id).toBe(movieId); 150 | }); 151 | 152 | it('should throw 404 error if movie does not exist', async () => { 153 | const movieId = 999999; 154 | 155 | const { body, statusCode } = await await request(app.getHttpServer()) 156 | .get(`/movie/${movieId}`) 157 | .set('authorization', `Bearer ${token}`); 158 | 159 | expect(statusCode).toBe(404); 160 | }); 161 | }); 162 | 163 | describe('[POST /movie]', () => { 164 | it('should create movie', async () => { 165 | const { body: { fileName } } = await request(app.getHttpServer()) 166 | .post(`/common/video`) 167 | .set('authorization', `Bearer ${token}`) 168 | .attach('video', Buffer.from('test'), 'movie.mp4') 169 | .expect(201); 170 | 171 | const dto = { 172 | title: 'Test Movie', 173 | detail: 'Test Movie Detail', 174 | directorId: directors[0].id, 175 | genreIds: genres.map(x => x.id), 176 | movieFileName: fileName, 177 | }; 178 | 179 | const { body, statusCode } = await request(app.getHttpServer()) 180 | .post(`/movie`) 181 | .set('authorization', `Bearer ${token}`) 182 | .send(dto); 183 | 184 | expect(statusCode).toBe(201); 185 | 186 | expect(body).toBeDefined(); 187 | expect(body.title).toBe(dto.title); 188 | expect(body.detail.detail).toBe(dto.detail); 189 | expect(body.director.id).toBe(dto.directorId); 190 | expect(body.genres.map(x => x.id)).toEqual(dto.genreIds); 191 | expect(body.movieFilePath).toContain(fileName); 192 | }); 193 | }); 194 | 195 | describe('[PATCH /movie/{id}]', () => { 196 | it('should update movie if exists', async () => { 197 | const dto = { 198 | title: 'Updated Test Movie', 199 | detail: 'Updated Test Movie Detail', 200 | directorId: directors[0].id, 201 | genreIds: [genres[0].id], 202 | }; 203 | 204 | const movieId = movies[0].id; 205 | 206 | const { body, statusCode } = await request(app.getHttpServer()) 207 | .patch(`/movie/${movieId}`) 208 | .set('authorization', `Bearer ${token}`) 209 | .send(dto); 210 | 211 | expect(statusCode).toBe(200); 212 | 213 | expect(body).toBeDefined(); 214 | expect(body.title).toBe(dto.title); 215 | expect(body.detail.detail).toBe(dto.detail); 216 | expect(body.director.id).toBe(dto.directorId); 217 | expect(body.genres.map(x => x.id)).toEqual(dto.genreIds); 218 | }); 219 | }); 220 | 221 | describe('[DELETE /movie/{id}]', () => { 222 | it('should delete existing movie', async () => { 223 | const movieId = movies[0].id; 224 | 225 | const { body, statusCode } = await request(app.getHttpServer()) 226 | .patch(`/movie/${movieId}`) 227 | .set('authorization', `Bearer ${token}`); 228 | 229 | expect(statusCode).toBe(200); 230 | }); 231 | 232 | it('should throw 404 error if movie does not exist', async () => { 233 | const movieId = 99999; 234 | 235 | const { statusCode } = await request(app.getHttpServer()) 236 | .patch(`/movie/${movieId}`) 237 | .set('authorization', `Bearer ${token}`); 238 | 239 | expect(statusCode).toBe(404); 240 | }); 241 | }); 242 | 243 | describe('[POST /movie/{id}/like]', () => { 244 | it('should like a movie', async () => { 245 | const movieId = movies[1].id; 246 | 247 | const { statusCode, body } = await request(app.getHttpServer()) 248 | .post(`/movie/${movieId}/like`) 249 | .set('authorization', `Bearer ${token}`); 250 | 251 | expect(statusCode).toBe(201); 252 | 253 | expect(body).toBeDefined(); 254 | expect(body.isLike).toBe(true); 255 | }); 256 | 257 | it('should cancel like a movie', async () => { 258 | const movieId = movies[1].id; 259 | 260 | const { statusCode, body } = await request(app.getHttpServer()) 261 | .post(`/movie/${movieId}/like`) 262 | .set('authorization', `Bearer ${token}`); 263 | 264 | expect(statusCode).toBe(201); 265 | 266 | expect(body).toBeDefined(); 267 | expect(body.isLike).toBeNull(); 268 | }); 269 | }); 270 | 271 | describe('[POST /movie/{id}/dislike]', () => { 272 | it('should dislike a movie', async () => { 273 | const movieId = movies[1].id; 274 | 275 | const { statusCode, body } = await request(app.getHttpServer()) 276 | .post(`/movie/${movieId}/dislike`) 277 | .set('authorization', `Bearer ${token}`); 278 | 279 | expect(statusCode).toBe(201); 280 | 281 | expect(body).toBeDefined(); 282 | expect(body.isLike).toBe(false); 283 | }); 284 | 285 | it('should cancel dislike a movie', async () => { 286 | const movieId = movies[1].id; 287 | 288 | const { statusCode, body } = await request(app.getHttpServer()) 289 | .post(`/movie/${movieId}/dislike`) 290 | .set('authorization', `Bearer ${token}`); 291 | 292 | expect(statusCode).toBe(201); 293 | 294 | expect(body).toBeDefined(); 295 | expect(body.isLike).toBeNull(); 296 | }); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /src/movie/movie.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MovieService } from './movie.service'; 3 | import { MovieController } from './movie.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { CommonModule } from 'src/common/common.module'; 6 | import { MulterModule } from '@nestjs/platform-express'; 7 | import { diskStorage } from 'multer'; 8 | import {join} from 'path'; 9 | import {v4} from 'uuid'; 10 | import { CacheModule } from '@nestjs/cache-manager'; 11 | import { MongooseModule } from '@nestjs/mongoose'; 12 | import { Movie, MovieSchema } from './schema/movie.schema'; 13 | import { MovieDetail, MovieDetailSchema } from './schema/movie-detail.schema'; 14 | import { MovieUserLike, MovieUserLikeSchema } from './schema/movie-user-like.schema'; 15 | import { Director, DirectorSchema } from 'src/director/schema/director.schema'; 16 | import { Genre, GenreSchema } from 'src/genre/schema/genre.schema'; 17 | import { User, UserSchema } from 'src/user/schema/user.schema'; 18 | 19 | @Module({ 20 | imports:[ 21 | // TypeOrmModule.forFeature([ 22 | // Movie, 23 | // MovieDetail, 24 | // MovieUserLike, 25 | // Director, 26 | // Genre, 27 | // User, 28 | // ]), 29 | MongooseModule.forFeature([ 30 | { 31 | name: Movie.name, 32 | schema: MovieSchema, 33 | }, 34 | { 35 | name: MovieDetail.name, 36 | schema: MovieDetailSchema, 37 | }, 38 | { 39 | name: MovieUserLike.name, 40 | schema: MovieUserLikeSchema 41 | }, 42 | { 43 | name: Director.name, 44 | schema: DirectorSchema, 45 | }, 46 | { 47 | name: Genre.name, 48 | schema: GenreSchema, 49 | }, 50 | { 51 | name: User.name, 52 | schema: UserSchema, 53 | }, 54 | ]), 55 | CommonModule, 56 | 57 | // MulterModule.register({ 58 | // storage: diskStorage({ 59 | // /// ......./Netflix/public/movie 60 | // /// process.cwd() + '/public' + '/movie' 61 | // /// process.cwd() + '\public' + '\movie' 62 | // destination: join(process.cwd(), 'public', 'movie'), 63 | // filename: (req, file, cb) => { 64 | // const split = file.originalname.split('.'); 65 | 66 | // let extension = 'mp4'; 67 | 68 | // if (split.length > 1) { 69 | // extension = split[split.length - 1]; 70 | // } 71 | 72 | // cb(null, `${v4()}_${Date.now()}.${extension}`); 73 | // } 74 | // }), 75 | // }), 76 | ], 77 | controllers: [MovieController], 78 | providers: [MovieService], 79 | }) 80 | export class MovieModule {} 81 | -------------------------------------------------------------------------------- /src/movie/movie.service.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, CACHE_MANAGER, CacheManagerOptions, CacheModule } from "@nestjs/cache-manager" 2 | import { TestingModule, Test } from "@nestjs/testing" 3 | import { TypeOrmModule } from "@nestjs/typeorm" 4 | import { Movie } from "./entity/movie.entity" 5 | import { MovieDetail } from "./entity/movie-detail.entity" 6 | import { Director } from "src/director/entity/director.entity" 7 | import { Genre } from "src/genre/entity/genre.entity" 8 | import { User } from "src/user/entity/user.entity" 9 | import { MovieUserLike } from "./entity/movie-user-like.entity" 10 | import { MovieService } from "./movie.service" 11 | import { CommonService } from "src/common/common.service" 12 | import { DataSource } from "typeorm" 13 | import { before } from "node:test" 14 | import { CreateMovieDto } from "./dto/create-movie.dto" 15 | import { UpdateMovieDto } from "./dto/update-movie.dto" 16 | import { NotFoundError } from "rxjs" 17 | import { NotFoundException } from "@nestjs/common" 18 | import { ConfigModule } from "@nestjs/config" 19 | 20 | describe('MovieService - Integration Test', () => { 21 | let service: MovieService; 22 | let cacheManager: Cache; 23 | let dataSource: DataSource; 24 | 25 | let users: User[]; 26 | let directors: Director[]; 27 | let movies: Movie[]; 28 | let genres: Genre[]; 29 | 30 | beforeAll(async () => { 31 | const module: TestingModule = await Test.createTestingModule({ 32 | imports: [ 33 | CacheModule.register(), 34 | TypeOrmModule.forRoot({ 35 | type: 'sqlite', 36 | database: ':memory:', 37 | dropSchema: true, 38 | entities: [ 39 | Movie, 40 | MovieDetail, 41 | Director, 42 | Genre, 43 | User, 44 | MovieUserLike, 45 | ], 46 | synchronize: true, 47 | logging: false, 48 | }), 49 | TypeOrmModule.forFeature( 50 | [ 51 | Movie, 52 | MovieDetail, 53 | Director, 54 | Genre, 55 | User, 56 | MovieUserLike, 57 | ] 58 | ), 59 | ConfigModule.forRoot(), 60 | ], 61 | providers: [MovieService, CommonService] 62 | }).compile(); 63 | 64 | service = module.get(MovieService); 65 | cacheManager = module.get(CACHE_MANAGER); 66 | dataSource = module.get(DataSource); 67 | }); 68 | 69 | it('should be defined', () => { 70 | expect(service).toBeDefined(); 71 | }); 72 | 73 | afterAll(async () => { 74 | await dataSource.destroy(); 75 | }); 76 | 77 | beforeEach(async () => { 78 | await cacheManager.reset(); 79 | 80 | const movieRepository = dataSource.getRepository(Movie); 81 | const movieDetailRepository = dataSource.getRepository(MovieDetail); 82 | const userRepository = dataSource.getRepository(User); 83 | const directorRepository = dataSource.getRepository(Director); 84 | const genreRepository = dataSource.getRepository(Genre); 85 | 86 | users = [1, 2].map( 87 | (x) => userRepository.create({ 88 | id: x, 89 | email: `${x}@test.com`, 90 | password: `123123`, 91 | }) 92 | ); 93 | 94 | await userRepository.save(users); 95 | 96 | directors = [1, 2].map( 97 | x => directorRepository.create({ 98 | id: x, 99 | dob: new Date('1992-11-23'), 100 | nationality: 'South Korea', 101 | name: `Director Name ${x}`, 102 | }) 103 | ); 104 | 105 | await directorRepository.save(directors); 106 | 107 | genres = [1, 2].map( 108 | x => genreRepository.create({ 109 | id: x, 110 | name: `Genre ${x}`, 111 | }) 112 | ); 113 | 114 | await genreRepository.save(genres); 115 | 116 | movies = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map( 117 | x => movieRepository.create({ 118 | id: x, 119 | title: `Movie ${x}`, 120 | creator: users[0], 121 | genres: genres, 122 | likeCount: 0, 123 | dislikeCount: 0, 124 | detail: movieDetailRepository.create({ 125 | detail: `Movie Detail ${x}`, 126 | }), 127 | movieFilePath: 'movies/movie1.mp4', 128 | director: directors[0], 129 | createdAt: new Date(`2023-9-${x}`), 130 | }) 131 | ); 132 | 133 | await movieRepository.save(movies); 134 | }); 135 | 136 | describe('findRecent', () => { 137 | it('should return recent movies', async () => { 138 | const result = await service.findRecent() as Movie[]; 139 | 140 | let sortedResult = [...movies]; 141 | sortedResult.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) 142 | let sortedResultIds = sortedResult.slice(0, 10).map(x => x.id); 143 | 144 | expect(result).toHaveLength(10); 145 | expect(result.map(x => x.id)).toEqual(sortedResultIds); 146 | }); 147 | 148 | it('should cache recent movies', async () => { 149 | const result = await service.findRecent() as Movie[]; 150 | 151 | const cachedData = await cacheManager.get('MOVIE_RECENT'); 152 | 153 | expect(cachedData).toEqual(result); 154 | }); 155 | }); 156 | 157 | describe('findAll', () => { 158 | it('should return movies with correct titles', async () => { 159 | const dto = { 160 | title: 'Movie 15', 161 | order: ['createdAt_DESC'], 162 | take: 10, 163 | }; 164 | 165 | const result = await service.findAll(dto); 166 | 167 | expect(result.data).toHaveLength(1); 168 | expect(result.data[0].title).toBe(dto.title); 169 | expect(result.data[0]).not.toHaveProperty('likeStatus'); 170 | }); 171 | 172 | it('should return likeStatus if userId is provided', async () => { 173 | const dto = { order: ['createdAt_ASC'], take: 10 }; 174 | 175 | const result = await service.findAll(dto, users[0].id); 176 | 177 | expect(result.data).toHaveLength(10); 178 | expect(result.data[0]).toHaveProperty('likeStatus'); 179 | }); 180 | }); 181 | 182 | describe('findOne', ()=>{ 183 | it('should return movie correctly', async ()=>{ 184 | const movieId = movies[0].id; 185 | 186 | const result = await service.findOne(movieId); 187 | 188 | expect(result.id).toBe(movieId); 189 | }); 190 | 191 | it('should throw NotFoundException if movie does not exist', async ()=>{ 192 | await expect(service.findOne(999999999999)).rejects.toThrow(NotFoundException); 193 | }) 194 | }) 195 | 196 | describe('create', () => { 197 | beforeEach(() => { 198 | jest.spyOn(service, 'renameMovieFile').mockResolvedValue(); 199 | }); 200 | 201 | it('should create movie correctly', async () => { 202 | const createMovieDto: CreateMovieDto = { 203 | title: 'Test Movie', 204 | detail: 'A Test Movie Detail', 205 | directorId: directors[0].id, 206 | genreIds: genres.map(x => x.id), 207 | movieFileName: 'test.mp4', 208 | }; 209 | 210 | const result = await service.create(createMovieDto, users[0].id, dataSource.createQueryRunner()); 211 | 212 | expect(result.title).toBe(createMovieDto.title); 213 | expect(result.director.id).toBe(createMovieDto.directorId); 214 | expect(result.genres.map(g => g.id)).toEqual(genres.map(g => g.id)); 215 | expect(result.detail.detail).toBe(createMovieDto.detail); 216 | }); 217 | }); 218 | 219 | describe('update', () => { 220 | it('should update movie correctly', async () => { 221 | const movieId = movies[0].id; 222 | 223 | const updateMovieDto: UpdateMovieDto = { 224 | title: 'Changed Title', 225 | detail: 'Changed Detail', 226 | directorId: directors[1].id, 227 | genreIds: [genres[0].id], 228 | }; 229 | 230 | const result = await service.update(movieId, updateMovieDto); 231 | 232 | expect(result.title).toBe(updateMovieDto.title); 233 | expect(result.detail.detail).toBe(updateMovieDto.detail); 234 | expect(result.director.id).toBe(updateMovieDto.directorId); 235 | expect(result.genres.map(x => x.id)).toEqual(updateMovieDto.genreIds); 236 | }); 237 | 238 | it('should throw error if movie does not exist', async () => { 239 | const updateMovieDto: UpdateMovieDto = { 240 | title: 'Change', 241 | }; 242 | 243 | await expect(service.update(9999999, updateMovieDto)).rejects.toThrow(NotFoundException); 244 | }) 245 | }); 246 | 247 | describe('remove', () => { 248 | it('should remove movie correctly', async () => { 249 | const removeId = movies[0].id; 250 | const result = await service.remove(removeId); 251 | 252 | expect(result).toBe(removeId); 253 | }); 254 | 255 | it('should throw error if movie does not exist', async()=>{ 256 | await expect(service.remove(999999)).rejects.toThrow(NotFoundException); 257 | }) 258 | }) 259 | 260 | describe('toggleMovieLike', ()=>{ 261 | it('should create like correctly', async ()=>{ 262 | const userId = users[0].id; 263 | const movieId = movies[0].id; 264 | 265 | const result = await service.toggleMovieLike(movieId, userId, true); 266 | 267 | expect(result).toEqual({isLike: true}); 268 | }); 269 | 270 | it('should create dislike correctly', async ()=>{ 271 | const userId = users[0].id; 272 | const movieId = movies[0].id; 273 | 274 | const result = await service.toggleMovieLike(movieId, userId, false); 275 | 276 | expect(result).toEqual({isLike: false}); 277 | }); 278 | 279 | it('should toggle like correctly', async ()=>{ 280 | const userId = users[0].id; 281 | const movieId = movies[0].id; 282 | 283 | await service.toggleMovieLike(movieId, userId, true); 284 | const result = await service.toggleMovieLike(movieId, userId, true); 285 | 286 | expect(result.isLike).toBeNull(); 287 | }); 288 | 289 | it('should toggle dislike correctly', async ()=>{ 290 | const userId = users[0].id; 291 | const movieId = movies[0].id; 292 | 293 | await service.toggleMovieLike(movieId, userId, false); 294 | const result = await service.toggleMovieLike(movieId, userId, false); 295 | 296 | expect(result.isLike).toBeNull(); 297 | }); 298 | }); 299 | }); -------------------------------------------------------------------------------- /src/movie/pipe/movie-file.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; 2 | import {v4} from 'uuid'; 3 | import {rename} from 'fs/promises'; 4 | import {join} from 'path'; 5 | 6 | @Injectable() 7 | export class MovieFilePipe implements PipeTransform>{ 8 | constructor( 9 | private readonly options:{ 10 | /// MB로 입력 11 | maxSize: number, 12 | mimetype: string, 13 | } 14 | ){ 15 | } 16 | 17 | async transform(value: Express.Multer.File, metadata: ArgumentMetadata): Promise { 18 | if(!value){ 19 | throw new BadRequestException('movie 필드는 필수입니다!'); 20 | } 21 | 22 | const byteSize = this.options.maxSize * 1000000; 23 | 24 | if(value.size > byteSize){ 25 | throw new BadRequestException(`${this.options.maxSize}MB 이하의 사이즈만 업로드 가능합니다!`) 26 | } 27 | 28 | if(value.mimetype !== this.options.mimetype){ 29 | throw new BadRequestException(`${this.options.mimetype} 만 업로드 가능합니다!`); 30 | } 31 | 32 | const split = value.originalname.split('.'); 33 | 34 | let extension = 'mp4'; 35 | 36 | if (split.length > 1) { 37 | extension = split[split.length - 1]; 38 | } 39 | 40 | /// uuid_Date.mp4 41 | const filename = `${v4()}_${Date.now()}.${extension}`; 42 | const newPath = join(value.destination, filename); 43 | 44 | await rename(value.path, newPath); 45 | 46 | return { 47 | ...value, 48 | filename, 49 | path: newPath, 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/movie/pipe/movie-title-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class MovieTitleValidationPipe implements PipeTransform{ 5 | transform(value: string, metadata: ArgumentMetadata): string { 6 | if(!value){ 7 | return value; 8 | } 9 | 10 | /// 만약에 글자 길이가 2보다 작거나 같으면 에러 던지기! 11 | if(value.length <= 2){ 12 | throw new BadRequestException('영화의 제목은 3자 이상 작성해주세요!'); 13 | } 14 | 15 | return value; 16 | } 17 | } -------------------------------------------------------------------------------- /src/movie/schema/movie-detail.schema.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from "mongoose"; 2 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 3 | import { User } from 'src/user/schema/user.schema'; 4 | import { Movie } from "./movie.schema"; 5 | 6 | @Schema({ 7 | timestamps: true, 8 | }) 9 | export class MovieDetail extends Document { 10 | @Prop({ 11 | required: true, 12 | }) 13 | detail: string; 14 | 15 | // @Prop({ 16 | // type: Types.ObjectId, 17 | // ref: 'Movie', 18 | // required: true, 19 | // unique: true, 20 | // }) 21 | // movie: Movie 22 | } 23 | 24 | export const MovieDetailSchema = SchemaFactory.createForClass(MovieDetail); -------------------------------------------------------------------------------- /src/movie/schema/movie-user-like.schema.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from "mongoose"; 2 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 3 | import { User } from 'src/user/schema/user.schema'; 4 | import { Movie } from "./movie.schema"; 5 | 6 | @Schema({ 7 | timestamps: true, 8 | }) 9 | export class MovieUserLike extends Document { 10 | @Prop({ 11 | type: Types.ObjectId, 12 | ref: 'Movie', 13 | required: true, 14 | }) 15 | movie: Movie; 16 | 17 | @Prop({ 18 | type: Types.ObjectId, 19 | ref: 'User', 20 | required: true, 21 | }) 22 | user: User; 23 | 24 | @Prop({ 25 | required: true, 26 | }) 27 | isLike: boolean; 28 | } 29 | 30 | export const MovieUserLikeSchema = SchemaFactory.createForClass(MovieUserLike); 31 | 32 | MovieUserLikeSchema.index({movie: 1, user: 1}, {unique: true}) -------------------------------------------------------------------------------- /src/movie/schema/movie.schema.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from "mongoose"; 2 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 3 | import { User } from 'src/user/schema/user.schema'; 4 | import { MovieDetail } from "./movie-detail.schema"; 5 | import { MovieUserLike } from "./movie-user-like.schema"; 6 | import { Genre } from "src/genre/schema/genre.schema"; 7 | import { Director } from "src/director/schema/director.schema"; 8 | 9 | @Schema({ 10 | timestamps: true, 11 | }) 12 | export class Movie extends Document { 13 | @Prop({ 14 | type: Types.ObjectId, 15 | ref: 'User', 16 | required: true, 17 | }) 18 | creator: User; 19 | 20 | @Prop({ 21 | unique: true, 22 | required: true, 23 | }) 24 | title: string; 25 | 26 | @Prop({ 27 | type: [{ 28 | type: Types.ObjectId, 29 | ref: 'Genre' 30 | }] 31 | }) 32 | genres: Genre[]; 33 | 34 | @Prop({ 35 | default: 0 36 | }) 37 | likeCount: number; 38 | 39 | @Prop({ 40 | default: 0 41 | }) 42 | dislikeCount: number; 43 | 44 | @Prop({ 45 | type: Types.ObjectId, 46 | ref: 'MovieDetail', 47 | required: true, 48 | }) 49 | detail: MovieDetail; 50 | 51 | @Prop({ 52 | required: true, 53 | }) 54 | movieFilePath: string; 55 | 56 | @Prop({ 57 | type: Types.ObjectId, 58 | ref: 'Director', 59 | required: true, 60 | }) 61 | director: Director; 62 | 63 | @Prop({ 64 | type: [{ 65 | type: Types.ObjectId, 66 | ref: 'MovieUserLike' 67 | }] 68 | }) 69 | likedUsers: MovieUserLike[]; 70 | } 71 | 72 | export const MovieSchema = SchemaFactory.createForClass(Movie); -------------------------------------------------------------------------------- /src/user/decorator/user-id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext, UnauthorizedException } from "@nestjs/common"; 2 | 3 | export const UserId = createParamDecorator( 4 | (data: unknown, context: ExecutionContext)=>{ 5 | const request = context.switchToHttp().getRequest(); 6 | 7 | return request?.user?.sub; 8 | } 9 | ); -------------------------------------------------------------------------------- /src/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from "class-validator"; 2 | 3 | export class CreateUserDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @IsString() 8 | password: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 5 | -------------------------------------------------------------------------------- /src/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from "class-transformer"; 2 | import { ChatRoom } from "src/chat/entity/chat-room.entity"; 3 | import { Chat } from "src/chat/entity/chat.entity"; 4 | import { BaseTable } from "src/common/entity/base-table.entity"; 5 | import { MovieUserLike } from "src/movie/entity/movie-user-like.entity"; 6 | import { Movie } from "src/movie/entity/movie.entity"; 7 | import { Column, Entity, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm"; 8 | 9 | export enum Role { 10 | admin, 11 | paidUser, 12 | user, 13 | } 14 | 15 | @Entity() 16 | export class User extends BaseTable { 17 | @PrimaryGeneratedColumn() 18 | id: number; 19 | 20 | @Column({ 21 | unique: true, 22 | }) 23 | email: string; 24 | 25 | @Column() 26 | @Exclude({ 27 | toPlainOnly: true, 28 | }) 29 | password: string; 30 | 31 | @Column({ 32 | enum: Role, 33 | default: Role.user, 34 | }) 35 | role: Role; 36 | 37 | @OneToMany( 38 | () => Movie, 39 | (movie) => movie.creator, 40 | ) 41 | createdMovies: Movie[]; 42 | 43 | @OneToMany( 44 | () => MovieUserLike, 45 | (mul) => mul.user, 46 | ) 47 | likedMovies: MovieUserLike[] 48 | 49 | @OneToMany( 50 | ()=> Chat, 51 | (chat) => chat.author, 52 | ) 53 | chats: Chat[]; 54 | 55 | @ManyToMany( 56 | ()=> ChatRoom, 57 | (chatRoom) => chatRoom.users, 58 | ) 59 | chatRooms: ChatRoom[]; 60 | } 61 | -------------------------------------------------------------------------------- /src/user/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "@prisma/client"; 2 | import { Types, Document } from "mongoose"; 3 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 4 | import { timestamp } from "rxjs"; 5 | import { Movie } from "src/movie/schema/movie.schema"; 6 | import { MovieUserLike } from "src/movie/schema/movie-user-like.schema"; 7 | import { Chat } from "src/chat/schema/chat.schema"; 8 | import { ChatRoom } from "src/chat/schema/chat-room.schema"; 9 | 10 | // export const userSchema = new Schema({ 11 | // id: Number, 12 | // email: String, 13 | // password: String, 14 | // role: Role, 15 | // createdMovies: [{ 16 | // type: Types.ObjectId, 17 | // ref: 'Movie', 18 | // }], 19 | // likedMovies: [{ 20 | // type: Types.ObjectId, 21 | // ref: 'MovieUserLike', 22 | // }], 23 | // chats: [{ 24 | // type: Types.ObjectId, 25 | // ref: 'Chat' 26 | // }], 27 | // chatRooms: [{ 28 | // type: Types.ObjectId, 29 | // ref: 'ChatRoom', 30 | // }], 31 | // }) 32 | 33 | @Schema({ 34 | timestamps: true, 35 | }) 36 | export class User extends Document { 37 | @Prop({ 38 | unique: true, 39 | required: true, 40 | }) 41 | email: string; 42 | 43 | @Prop({ 44 | required: true, 45 | select: false, 46 | }) 47 | password: string; 48 | 49 | @Prop({ 50 | enum: Role, 51 | default: Role.user, 52 | }) 53 | role: Role; 54 | 55 | @Prop({ 56 | type: [{ 57 | type: Types.ObjectId, 58 | ref: 'Movie' 59 | }], 60 | }) 61 | createdMovies: Movie[]; 62 | 63 | @Prop({ 64 | type: [{ 65 | type: Types.ObjectId, 66 | ref: 'MovieUserLike' 67 | }] 68 | }) 69 | likedMovies: MovieUserLike[] 70 | 71 | @Prop({ 72 | type: [{ 73 | type: Types.ObjectId, 74 | ref: 'Chat', 75 | }] 76 | }) 77 | chats: Chat[]; 78 | 79 | @Prop({ 80 | type: [{ 81 | type: Types.ObjectId, 82 | ref: 'ChatRoom', 83 | }] 84 | }) 85 | chatRooms: ChatRoom[]; 86 | } 87 | 88 | export const UserSchema = SchemaFactory.createForClass(User); -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | import { CreateUserDto } from './dto/create-user.dto'; 5 | import { Role, User } from './entity/user.entity'; 6 | import { UpdateUserDto } from './dto/update-user.dto'; 7 | 8 | const mockedUserService = { 9 | create: jest.fn(), 10 | findAll: jest.fn(), 11 | findOne: jest.fn(), 12 | update: jest.fn(), 13 | remove: jest.fn(), 14 | } 15 | 16 | describe('UserController', () => { 17 | let userController: UserController; 18 | let userService: UserService; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | controllers: [UserController], 23 | providers: [ 24 | { 25 | provide: UserService, 26 | useValue: mockedUserService, 27 | } 28 | ], 29 | }).compile(); 30 | 31 | userController = module.get(UserController); 32 | userService = module.get(UserService); 33 | }); 34 | 35 | it('should be defined', () => { 36 | expect(userController).toBeDefined(); 37 | }); 38 | 39 | describe("create", () => { 40 | it('should return correct value', async () => { 41 | const createUserDto: CreateUserDto = { 42 | email: 'test@codefactory.ai', 43 | password: '123123', 44 | } 45 | 46 | const user = { 47 | id: 1, 48 | ...createUserDto, 49 | password: 'asdvixczjvilsjadf', 50 | }; 51 | 52 | jest.spyOn(userService, 'create').mockResolvedValue(user as User); 53 | 54 | const result = await userController.create(createUserDto); 55 | 56 | expect(userService.create).toHaveBeenCalledWith(createUserDto); 57 | expect(result).toEqual(user); 58 | }) 59 | }); 60 | 61 | describe("findAll", () => { 62 | it('should return a list of users', async () => { 63 | const users = [ 64 | { 65 | id: 1, 66 | email: 'test@codefactory.ai', 67 | }, 68 | { 69 | id: 2, 70 | email: 'jc@codefactory.ai' 71 | } 72 | ]; 73 | 74 | jest.spyOn(userService, 'findAll').mockResolvedValue(users as User[]); 75 | 76 | const result = await userController.findAll(); 77 | 78 | expect(userService.findAll).toHaveBeenCalled(); 79 | expect(result).toEqual(users); 80 | }) 81 | }); 82 | 83 | describe("findOne", () => { 84 | it('should return a single user', async () => { 85 | const user = { 86 | id: 1, 87 | email: 'test@codefactory.ai', 88 | }; 89 | 90 | jest.spyOn(userService, 'findOne').mockResolvedValue(user as User); 91 | 92 | const result = await userController.findOne(1); 93 | 94 | expect(userService.findOne).toHaveBeenCalledWith(1); 95 | expect(result).toEqual(user); 96 | }) 97 | }); 98 | 99 | describe("update", () => { 100 | it('should return the updated user', async () => { 101 | const id = 1; 102 | const updateUserDto: UpdateUserDto = { 103 | email: 'admin@codefactory.ai', 104 | }; 105 | const user = { 106 | id, 107 | ...updateUserDto, 108 | }; 109 | 110 | jest.spyOn(userService, 'update').mockResolvedValue(user as User); 111 | 112 | const result = await userController.update(1, updateUserDto); 113 | 114 | expect(userService.update).toHaveBeenCalledWith(1, updateUserDto); 115 | expect(result).toEqual(user); 116 | }) 117 | }); 118 | 119 | describe("findOne", () => { 120 | it('should return a single user', async () => { 121 | const id = 1; 122 | 123 | jest.spyOn(userService, 'remove').mockResolvedValue(id); 124 | 125 | const result = await userController.remove(1); 126 | 127 | expect(userService.findOne).toHaveBeenCalledWith(1); 128 | expect(result).toEqual(id); 129 | }) 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { UpdateUserDto } from './dto/update-user.dto'; 5 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 6 | 7 | @Controller('user') 8 | @ApiBearerAuth() 9 | @ApiTags('user') 10 | @UseInterceptors(ClassSerializerInterceptor) 11 | export class UserController { 12 | constructor(private readonly userService: UserService) {} 13 | 14 | @Post() 15 | create(@Body() createUserDto: CreateUserDto) { 16 | return this.userService.create(createUserDto); 17 | } 18 | 19 | @Get() 20 | findAll() { 21 | return this.userService.findAll(); 22 | } 23 | 24 | @Get(':id') 25 | findOne(@Param('id', ParseIntPipe) id: number) { 26 | return this.userService.findOne(id); 27 | } 28 | 29 | @Patch(':id') 30 | update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) { 31 | return this.userService.update(id, updateUserDto); 32 | } 33 | 34 | @Delete(':id') 35 | remove(@Param('id', ParseIntPipe) id: number) { 36 | return this.userService.remove(id); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | // import { User } from './entity/user.entity'; 6 | import { CommonModule } from 'src/common/common.module'; 7 | import { User, UserSchema } from './schema/user.schema'; 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | 10 | @Module({ 11 | imports: [ 12 | // TypeOrmModule.forFeature([ 13 | // User, 14 | // ]), 15 | MongooseModule.forFeature([ 16 | { 17 | name: User.name, 18 | schema: UserSchema, 19 | } 20 | ]), 21 | CommonModule, 22 | ], 23 | controllers: [UserController], 24 | providers: [UserService], 25 | exports: [UserService], 26 | }) 27 | export class UserModule { } 28 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { User } from './entity/user.entity'; 5 | import { BadRequestException, NotFoundException } from '@nestjs/common'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { CreateUserDto } from './dto/create-user.dto'; 8 | import * as bcrypt from 'bcrypt'; 9 | import { UpdateUserDto } from './dto/update-user.dto'; 10 | 11 | const mockUserRepository = { 12 | findOne: jest.fn(), 13 | save: jest.fn(), 14 | find: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn(), 17 | } 18 | 19 | const mockConfigService = { 20 | get: jest.fn(), 21 | }; 22 | 23 | describe('UserService', () => { 24 | let userService: UserService; 25 | 26 | beforeEach(async () => { 27 | const module: TestingModule = await Test.createTestingModule({ 28 | providers: [ 29 | UserService, 30 | { 31 | provide: getRepositoryToken(User), 32 | useValue: mockUserRepository, 33 | }, 34 | { 35 | provide: ConfigService, 36 | useValue: mockConfigService, 37 | } 38 | ], 39 | }).compile(); 40 | 41 | userService = module.get(UserService); 42 | }); 43 | 44 | afterEach(()=>{ 45 | jest.clearAllMocks(); 46 | }); 47 | 48 | it('should be defined', () => { 49 | expect(userService).toBeDefined(); 50 | }); 51 | 52 | describe("create", () => { 53 | it('should create a new user and return it', async () => { 54 | const createUserDto: CreateUserDto = { 55 | email: 'test@codefactory.ai', 56 | password: '123123' 57 | }; 58 | const hashRounds = 10; 59 | const hashedPassword = 'hashashhashhcivhasdf'; 60 | const result = { 61 | id: 1, 62 | email: createUserDto.email, 63 | password: hashedPassword, 64 | }; 65 | 66 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValueOnce(null); 67 | jest.spyOn(mockConfigService, 'get').mockReturnValue(hashRounds); 68 | jest.spyOn(bcrypt, 'hash').mockImplementation((password, hashRound) => hashedPassword); 69 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValueOnce(result); 70 | 71 | const createdUser = await userService.create(createUserDto); 72 | 73 | expect(createdUser).toEqual(result); 74 | expect(mockUserRepository.findOne).toHaveBeenNthCalledWith(1, { where: { email: createUserDto.email } }); 75 | expect(mockUserRepository.findOne).toHaveBeenNthCalledWith(2, { where: { email: createUserDto.email } }); 76 | expect(mockConfigService.get).toHaveBeenCalledWith(expect.anything()); 77 | expect(bcrypt.hash).toHaveBeenCalledWith(createUserDto.password, hashRounds); 78 | expect(mockUserRepository.save).toHaveBeenCalledWith({ 79 | email: createUserDto.email, 80 | password: hashedPassword, 81 | }) 82 | }); 83 | 84 | it('should throw a BadRequestException if email already exists', () => { 85 | const createUserDto: CreateUserDto = { 86 | email: 'test@codefactory.ai', 87 | password: '123123', 88 | }; 89 | 90 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue({ 91 | id: 1, 92 | email: createUserDto.email, 93 | }); 94 | 95 | expect(userService.create(createUserDto)).rejects.toThrow(BadRequestException); 96 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { email: createUserDto.email } }) 97 | }); 98 | }) 99 | 100 | describe("findAll", () => { 101 | it('should return all users', async () => { 102 | const users = [ 103 | { 104 | id: 1, 105 | email: 'test@codefactory.ai', 106 | }, 107 | ]; 108 | mockUserRepository.find.mockResolvedValue(users); 109 | 110 | const result = await userService.findAll(); 111 | 112 | expect(result).toEqual(users); 113 | expect(mockUserRepository.find).toHaveBeenCalled(); 114 | }); 115 | }); 116 | 117 | describe("findOne", () => { 118 | it('should return a user by id', async () => { 119 | const user = { id: 1, email: "test@codefactory.ai" }; 120 | 121 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(user); 122 | 123 | const result = await userService.findOne(1); 124 | 125 | expect(result).toEqual(user); 126 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 127 | where: { 128 | id: 1, 129 | }, 130 | }); 131 | }); 132 | 133 | it('should throw a NotFoundException if user is not found', async () => { 134 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(null); 135 | 136 | expect(userService.findOne(999)).rejects.toThrow(NotFoundException); 137 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 138 | where: { 139 | id: 999, 140 | } 141 | }) 142 | }); 143 | }); 144 | 145 | describe("update", ()=> { 146 | it('should update a user if it exists and return the updated user', async ()=>{ 147 | const updateUserDto: UpdateUserDto = { 148 | email: 'test@codefactory.ai', 149 | password: '123123', 150 | }; 151 | const hashRounds = 10; 152 | const hashedPassword = 'hashashndvizxcjnvkolisadf'; 153 | const user = { 154 | id: 1, 155 | email: updateUserDto.email, 156 | }; 157 | 158 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValueOnce(user); 159 | jest.spyOn(mockConfigService, 'get').mockReturnValue(hashRounds); 160 | jest.spyOn(bcrypt, 'hash').mockImplementation((pass, hashRounds) => hashedPassword); 161 | jest.spyOn(mockUserRepository, 'update').mockResolvedValue(undefined); 162 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValueOnce({ 163 | ...user, 164 | password: hashedPassword, 165 | }); 166 | 167 | const result = await userService.update(1, updateUserDto); 168 | 169 | expect(result).toEqual({ 170 | ...user, 171 | password: hashedPassword, 172 | }); 173 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 174 | where:{ 175 | id:1, 176 | }, 177 | }); 178 | expect(bcrypt.hash).toHaveBeenCalledWith(updateUserDto.password, hashRounds); 179 | expect(mockUserRepository.update).toHaveBeenCalledWith({ 180 | id: 1, 181 | }, { 182 | ...updateUserDto, 183 | password: hashedPassword, 184 | }) 185 | }); 186 | 187 | it('should throw a NotFoundException if user to update is not found', async ()=>{ 188 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(null); 189 | 190 | const updateUserDto: UpdateUserDto = { 191 | email: 'test@codefactory.ai', 192 | password: '123123', 193 | }; 194 | 195 | expect(userService.update(999, updateUserDto)).rejects.toThrow(NotFoundException); 196 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 197 | where:{ 198 | id: 999, 199 | }, 200 | }); 201 | expect(mockUserRepository.update).not.toHaveBeenCalled(); 202 | }) 203 | }); 204 | 205 | describe('remove', () => { 206 | it('should delete a user by id', async () => { 207 | const id = 999; 208 | 209 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue({ 210 | id: 1, 211 | }); 212 | 213 | const result = await userService.remove(id); 214 | 215 | expect(result).toEqual(id); 216 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 217 | where: { 218 | id, 219 | } 220 | }) 221 | }); 222 | 223 | it('should throw a NotFoundException if user to delete is not found', () => { 224 | jest.spyOn(mockUserRepository, 'findOne').mockResolvedValue(null); 225 | 226 | expect(userService.remove(999)).rejects.toThrow(NotFoundException); 227 | expect(mockUserRepository.findOne).toHaveBeenCalledWith({ 228 | where: { 229 | id: 999, 230 | } 231 | }) 232 | }) 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { CreateUserDto } from './dto/create-user.dto'; 3 | import { UpdateUserDto } from './dto/update-user.dto'; 4 | import { Repository } from 'typeorm'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | // import { User } from './entity/user.entity'; 7 | import { NotFoundError } from 'rxjs'; 8 | import * as bcrypt from 'bcrypt'; 9 | import { envVariableKeys } from 'src/common/const/env.const'; 10 | import { ConfigService } from '@nestjs/config'; 11 | import { PrismaService } from 'src/common/prisma.service'; 12 | import { InjectModel } from '@nestjs/mongoose'; 13 | import { User } from './schema/user.schema'; 14 | import { Model } from 'mongoose'; 15 | 16 | @Injectable() 17 | export class UserService { 18 | constructor( 19 | // @InjectRepository(User) 20 | // private readonly userRepository: Repository, 21 | private readonly configService: ConfigService, 22 | // private readonly prisma: PrismaService, 23 | @InjectModel(User.name) 24 | private readonly userModel: Model, 25 | ) { } 26 | 27 | async create(createUserDto: CreateUserDto) { 28 | const { email, password } = createUserDto; 29 | 30 | const user = await this.userModel.findOne({email}).exec(); 31 | // const user = await this.prisma.user.findUnique({ 32 | // where: { 33 | // email, 34 | // } 35 | // }) 36 | 37 | // const user = await this.userRepository.findOne({ 38 | // where: { 39 | // email, 40 | // }, 41 | // }); 42 | 43 | if (user) { 44 | throw new BadRequestException('이미 가입한 이메일 입니다!'); 45 | } 46 | 47 | const hash = await bcrypt.hash(password, this.configService.get(envVariableKeys.hashRounds)) 48 | 49 | // const newUser = new this.userModel({ 50 | // email, 51 | // password: hash, 52 | // }); 53 | 54 | // await newUser.save(); 55 | await this.userModel.create({ 56 | email, 57 | password: hash, 58 | }); 59 | 60 | // await this.prisma.user.create({ 61 | // data: { 62 | // email, 63 | // password: hash, 64 | // } 65 | // }) 66 | 67 | // await this.userRepository.save({ 68 | // email, 69 | // password: hash, 70 | // }); 71 | 72 | return this.userModel.findOne({email}, { 73 | createdMovies: 0, 74 | likedMovies: 0, 75 | chats: 0, 76 | chatRooms: 0, 77 | }).exec(); 78 | // return this.prisma.user.findUnique({ 79 | // where:{ 80 | // email, 81 | // } 82 | // }); 83 | 84 | // return this.userRepository.findOne({ 85 | // where: { 86 | // email, 87 | // }, 88 | // }); 89 | } 90 | 91 | findAll() { 92 | return this.userModel.find().exec(); 93 | // return this.prisma.user.findMany(); 94 | // return this.userRepository.find(); 95 | } 96 | 97 | async findOne(id: number) { 98 | const user = await this.userModel.findById(id); 99 | 100 | // const user = await this.prisma.user.findUnique({ 101 | // where: { 102 | // id, 103 | // } 104 | // }) 105 | 106 | // const user = await this.userRepository.findOne({ 107 | // where: { 108 | // id, 109 | // } 110 | // }); 111 | 112 | if (!user) { 113 | throw new NotFoundException('존재하지 않는 사용자입니다!'); 114 | } 115 | 116 | return user; 117 | } 118 | 119 | async update(id: number, updateUserDto: UpdateUserDto) { 120 | const { password } = updateUserDto; 121 | 122 | const user = await this.userModel.findById(id); 123 | // const user = await this.prisma.user.findUnique({ 124 | // where: { 125 | // id, 126 | // } 127 | // }) 128 | 129 | // const user = await this.userRepository.findOne({ 130 | // where: { 131 | // id, 132 | // } 133 | // }); 134 | 135 | if (!user) { 136 | throw new NotFoundException('존재하지 않는 사용자입니다!'); 137 | } 138 | 139 | let input = { 140 | ...updateUserDto, 141 | } 142 | 143 | if(password){ 144 | const hash = await bcrypt.hash(password, this.configService.get(envVariableKeys.hashRounds)); 145 | 146 | input = { 147 | ...input, 148 | password: hash, 149 | } 150 | } 151 | 152 | await this.userModel.findByIdAndUpdate(id, input).exec(); 153 | // await this.prisma.user.update({ 154 | // where:{ 155 | // id, 156 | // }, 157 | // data:input, 158 | // }) 159 | 160 | // await this.userRepository.update( 161 | // { id }, 162 | // { 163 | // ...updateUserDto, 164 | // password: hash, 165 | // }, 166 | // ); 167 | 168 | return this.userModel.findById(id); 169 | // return this.prisma.user.findUnique({ 170 | // where: { 171 | // id, 172 | // }, 173 | // }); 174 | 175 | // return this.userRepository.findOne({ 176 | // where: { 177 | // id, 178 | // }, 179 | // }); 180 | } 181 | 182 | async remove(id: number) { 183 | const user = await this.userModel.findById(id); 184 | // const user = await this.prisma.user.findUnique({ 185 | // where: { 186 | // id, 187 | // } 188 | // }) 189 | // const user = await this.userRepository.findOne({ 190 | // where: { 191 | // id, 192 | // } 193 | // }); 194 | 195 | if (!user) { 196 | throw new NotFoundException('존재하지 않는 사용자입니다!'); 197 | } 198 | 199 | await this.userModel.findByIdAndDelete(id); 200 | // await this.prisma.user.delete({ 201 | // where:{ 202 | // id 203 | // }, 204 | // }) 205 | // await this.userRepository.delete(id); 206 | 207 | return id; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/worker/thumbnail-generation.worker.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from "@nestjs/bullmq"; 2 | import { Job } from "bullmq"; 3 | import { join } from "path"; 4 | import { cwd } from "process"; 5 | import * as ffmpegFluent from 'fluent-ffmpeg'; 6 | 7 | @Processor('thumbnail-generation') 8 | export class ThumbnailGenerationProcess extends WorkerHost { 9 | async process(job: Job, token?: string): Promise { 10 | const { videoPath, videoId } = job.data; 11 | 12 | console.log(`영상 트랜스코딩중... ID: ${videoId}`); 13 | 14 | const outputDirectory = join(cwd(), 'public', 'thumbnail'); 15 | 16 | ffmpegFluent(videoPath) 17 | .screenshots({ 18 | count: 1, 19 | filename: `${videoId}.png`, 20 | folder: outputDirectory, 21 | size: '320x240', 22 | }) 23 | .on('end', ()=>{ 24 | console.log(`썸네일 생성 완료! ID: ${videoId}`) 25 | }) 26 | .on('error', (error)=>{ 27 | console.log(error); 28 | console.log(`썸네일 생성 실패! ID: ${videoId}`) 29 | }); 30 | } 31 | } -------------------------------------------------------------------------------- /src/worker/worker.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ThumbnailGenerationProcess } from "./thumbnail-generation.worker"; 3 | 4 | @Module({ 5 | providers: [ThumbnailGenerationProcess] 6 | }) 7 | export class WorkerModule{} -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------