diff --git a/.docker.env b/.docker.env new file mode 100644 index 0000000..8c3558d --- /dev/null +++ b/.docker.env @@ -0,0 +1,27 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="mysql://root:hoplin1234!@db:3306/judge?schema=public" + +ADMIN_EMAIL="hoplin.dev@gmail.com" +ADMIN_PW = "admin" + +JWT_SECRET="SECRET" + +JUDGE_SERVER_ENDPOINT="" + +ENV="" +PORT="" + +# AWS +AWS_REGION="" +AWS_ACCESS_ID="" +AWS_ACCESS_SECRET="" +AWS_SQS_QUEUE="" +AWS_S3_BUCKET="" + +# Sentry +SENTRY_DSN="" diff --git a/.env b/.env index 96d2858..f8ccca8 100644 --- a/.env +++ b/.env @@ -11,4 +11,17 @@ ADMIN_PW = "admin" JWT_SECRET="SECRET" -JUDGE_SERVER_ENDPOINT="a" +JUDGE_SERVER_ENDPOINT="" + +ENV="" +PORT="" + +# AWS +AWS_REGION="" +AWS_ACCESS_ID="" +AWS_ACCESS_SECRET="" +AWS_SQS_QUEUE="" +AWS_S3_BUCKET="" + +# Sentry +SENTRY_DSN="" \ No newline at end of file diff --git a/.github/workflows/pre-test.yml b/.github/workflows/pre-test.yml index aa807c6..766e076 100644 --- a/.github/workflows/pre-test.yml +++ b/.github/workflows/pre-test.yml @@ -1,19 +1,12 @@ -name: CI-Unit-Test +name: CI-Pull-Request-Pretest on: pull_request: branches: - dev - workflow_call: - inputs: - build_id: - required: true - type: number - deploy_target: - required: true - type: string run-name: Unit/E2E test of ${{ github.ref_name }} by ${{ github.actor }} jobs: - unit-testing: + build: + name: Application Build Test & Set up & Initialize Test Database Server runs-on: ubuntu-latest steps: # Checkout repository codes @@ -24,6 +17,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 18 + cache: yarn - name: Initialize .ci.env run: | touch .ci.env @@ -32,18 +26,70 @@ jobs: echo "ADMIN_PW=${{secrets.ADMIN_PW}}" >> .ci.env echo "JWT_SECRET=${{secrets.JWT_SECRET}}" >> .ci.env echo "JUDGE_SERVER_ENDPOINT=${{secrets.JUDGE_SERVER_ENDPOINT}}" >> .ci.env + echo "AWS_REGION"=${{secrets.AWS_REGION}}>> .ci.env + echo "AWS_ACCESS_ID=${{secrets.AWS_ACCESS_ID}}" >> .ci.env + echo "AWS_ACCESS_SECRET=${{secrets.AWS_ACCESS_SECRET}}" >> .ci.env + echo "AWS_SQS_QUEUE=${{secrets.AWS_SQS_QUEUE}}" >> .ci.env + echo "AWS_S3_BUCKET=${{secrets.AWS_S3_BUCKET}}" >> .ci.env - name: Install Node.js Dependencies - run: npm install --force - - name: Install dotenv-cli for dotenv cli - run: npm install -g dotenv-cli + run: yarn install --force + - name: Install dotenv-cli for CI script + run: yarn global add dotenv-cli + - id: build-phase + name: Check Nest.js Application Build + continue-on-error: true + run: yarn build + # Push Prisma Schema to test DB - name: Initialize Prisma Client - run: npm run ci:init + run: yarn ci:init + # Preserve Artifacts for test + - name: Preserve Artifacts + uses: actions/upload-artifact@v4 + with: + name: build-result + path: . + - name: 'Discord Alert - Success' + if: steps.build-phase.outcome == 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: info + details: 'Build & Setup: Success' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Discord Alert - Failed' + if: steps.build-phase.outcome != 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: error + details: 'Build & Setup: Fail' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Fail check' + if: steps.build-phase.outcome != 'success' + run: exit1 + + unit-testing: + needs: build + runs-on: ubuntu-latest + steps: + # Setup environment for Node.js 18 + - name: Node.js version 18 Environment setting + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Mount volume of pre-build + uses: actions/download-artifact@v4 + with: + name: build-result + path: . + - name: Grant Permission to artifacts + run: chmod -R 777 . + - name: Install dotenv-cli for CI script + run: yarn global add dotenv-cli - id: unit-test-phase name: Run Unit Test - run: npm run ci:unit + continue-on-error: true + run: yarn ci:unit - name: 'Discord Alert - Success' if: steps.unit-test-phase.outcome == 'success' - continue-on-error: true uses: rjstone/discord-webhook-notify@v1 with: severity: info @@ -56,37 +102,33 @@ jobs: severity: error details: 'Unit Test Result: Fail' webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Fail check' + if: steps.unit-test-phase.outcome != 'success' + run: exit1 e2e-testing: - needs: unit-testing + needs: build runs-on: ubuntu-latest steps: - - name: Repository Checkout - uses: actions/checkout@v4 - name: Node.js version 18 Environment setting uses: actions/setup-node@v4 with: node-version: 18 - - name: Initialize .ci.env - run: | - touch .ci.env - echo "DATABASE_URL=${{ secrets.DATABASE_URL}}" >> .ci.env - echo "ADMIN_EMAIL=${{secrets.ADMIN_EMAIL}}" >> .ci.env - echo "ADMIN_PW=${{secrets.ADMIN_PW}}" >> .ci.env - echo "JWT_SECRET=${{secrets.JWT_SECRET}}" >> .ci.env - echo "JUDGE_SERVER_ENDPOINT=${{secrets.JUDGE_SERVER_ENDPOINT}}" >> .ci.env - - name: Install Node.js Dependencies - run: npm install --force - - name: Install dotenv-cli for dotenv cli - run: npm install -g dotenv-cli - - name: Initialize Prisma Client - run: npm run ci:init + - name: Mount volume of pre-build + uses: actions/download-artifact@v4 + with: + name: build-result + path: . + - name: Grant Permission to artifacts + run: chmod -R 777 . + - name: Install dotenv-cli for CI script + run: yarn global add dotenv-cli - id: e2e-test-phase name: Run E2E Test - run: npm run ci:e2e + continue-on-error: true + run: yarn ci:e2e - name: 'Discord Alert - Success' if: steps.e2e-test-phase.outcome == 'success' - continue-on-error: true uses: rjstone/discord-webhook-notify@v1 with: severity: info @@ -99,3 +141,49 @@ jobs: severity: error details: 'E2E Test Result: Failed' webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Fail check' + if: steps.e2e-test-phase.outcome != 'success' + run: exit1 + + tear-down: + needs: [unit-testing, e2e-testing] + runs-on: ubuntu-latest + steps: + - name: Node.js version 18 Environment setting + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Mount volume of pre-build + uses: actions/download-artifact@v4 + with: + name: build-result + path: . + - name: Grant Permission to artifacts + run: chmod -R 777 . + - name: Install dotenv-cli for CI script + run: yarn global add dotenv-cli + - id: tear-down-phase + name: Test Database tear-down + continue-on-error: true + run: dotenv -e .ci.env -- node node_modules/prisma/build/index.js db push --force-reset + - uses: geekyeggo/delete-artifact@v4 + with: + name: build-result + token: ${{secrets.TOKEN}} + - name: 'Discord Alert - Success' + if: steps.tear-down-phase.outcome == 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: info + details: 'Test DB Tear down: Success' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Discord Alert - Fail' + if: steps.tear-down-phase.outcome != 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: error + details: 'Test DB Tear down: Failed' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Fail check' + if: steps.tear-down-phase.outcome != 'success' + run: exit1 diff --git a/.github/workflows/production-dockerize.yml b/.github/workflows/production-dockerize.yml new file mode 100644 index 0000000..1b4a284 --- /dev/null +++ b/.github/workflows/production-dockerize.yml @@ -0,0 +1,58 @@ +name: CI-Production-Dockerize +on: + push: + branches: + - production +run-name: Dockerize of production version. Triggerd by ${{github.actor}} +jobs: + dockerize: + name: Dockerize and upload + runs-on: ubuntu-latest + steps: + # Checkout repository code + - name: Repository Checkout + uses: actions/checkout@v4 + # Set Node.js version environment + - name: Node.js version 18 Environment setting + uses: actions/setup-node@v4 + with: + node-version: 18 + # Install dependencies + - name: Install Dependencies + run: npm install --force + - name: Check Build Status + run: npm run build + - name: Setup QEMU for multiplatform build + uses: docker/setup-qemu-action@v3 + - name: Setup docker buildx and set platform + uses: docker/setup-buildx-action@v3 + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{secrets.DOCKERHUB_USERNAME}} + password: ${{secrets.DOCKERHUB_TOKEN}} + - id: build-n-push + name: Build and Push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: hoplin-dev/online-judge:latest + platforms: linux/amd64,linux/arm64 + - name: 'Discord Alert - Success' + if: steps.build-n-push.outcome == 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: info + details: 'Docker Deploy Success (linux/amd64, linux/arm64)' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Discord Alert - Fail' + if: steps.build-n-push.outcome != 'success' + uses: rjstone/discord-webhook-notify@v1 + with: + severity: error + details: 'Docker Deploy Failed' + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} + - name: 'Fail check' + if: steps.build-n-push.outcome != 'success' + run: exit1 diff --git a/.platform/hooks/predeploy/01_database_init.sh b/.platform/hooks/predeploy/01_database_init.sh deleted file mode 100644 index 04cdc0a..0000000 --- a/.platform/hooks/predeploy/01_database_init.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -# Add database migration script -node node_modules/prisma/build/index.js migrate resolve --applied 20240109151719_submission_ispulic -node node_modules/prisma/build/index.js migrate resolve --applied 20240113073649_problem_issue_comment -node node_modules/prisma/build/index.js migrate resolve --applied 20240113161205_problem_issue_comment_user -node node_modules/prisma/build/index.js migrate resolve --applied 20240114010304_chagne_issue_comment_name -node node_modules/prisma/build/index.js migrate resolve --applied 20240114021108_issue_comment_problem_relation -node node_modules/prisma/build/index.js migrate resolve --applied 20240114021239_modify_problem_issue_problem diff --git a/.platform/hooks/predeploy/01_databse_migration.sh b/.platform/hooks/predeploy/01_databse_migration.sh new file mode 100644 index 0000000..20d602b --- /dev/null +++ b/.platform/hooks/predeploy/01_databse_migration.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash + diff --git a/.platform/hooks/predeploy/02_database_client_generate.sh b/.platform/hooks/predeploy/02_database_client_generate.sh new file mode 100644 index 0000000..915341f --- /dev/null +++ b/.platform/hooks/predeploy/02_database_client_generate.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +# Do not modify +node node_modules/prisma/build/index.js generate diff --git a/.platform/nginx/conf.d/base.conf b/.platform/nginx/conf.d/base.conf new file mode 100644 index 0000000..695d2a6 --- /dev/null +++ b/.platform/nginx/conf.d/base.conf @@ -0,0 +1,7 @@ +server { + listen 80; + + location / { + proxy_pass http://127.0.0.1:3000; + } +} diff --git a/.platform/nginx/conf.d/timeout.conf b/.platform/nginx/conf.d/timeout.conf new file mode 100644 index 0000000..851fb94 --- /dev/null +++ b/.platform/nginx/conf.d/timeout.conf @@ -0,0 +1 @@ +keepalive_timeout 120; \ No newline at end of file diff --git a/.test.env b/.test.env index a7fe04d..b70fe58 100644 --- a/.test.env +++ b/.test.env @@ -11,4 +11,17 @@ ADMIN_PW = "admin" JWT_SECRET="SECRET" -JUDGE_SERVER_ENDPOINT="a" +JUDGE_SERVER_ENDPOINT="test" + +ENV="" +PORT="" + +# AWS +AWS_REGION="test" +AWS_ACCESS_ID="test" +AWS_ACCESS_SECRET="test" +AWS_SQS_QUEUE="test" +AWS_S3_BUCKET="test" + +# Sentry +SENTRY_DSN="test" diff --git a/Dockerfile b/Dockerfile index 48bdeeb..f6ddb93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,7 @@ FROM node:21-bullseye COPY . . -RUN yarn install\ -npx prisma migrate +RUN yarn install - -CMD [ "start" ] +CMD [ "docker:start" ] ENTRYPOINT [ "yarn" ] \ No newline at end of file diff --git a/README.md b/README.md index 8372941..c586afd 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,196 @@ -

- Nest Logo -

+# Online Judge System API + +- Author: J-Hoplin +- Team + - J-Hoplin: Backend & Infrastructure + - Oseungkwon: Frontend -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +## Contents + +- [📦Diagram](#diagram) +- [📦Github Actions CI flow](#github-actions-ci-flow) +- [📊Test Coverage](#test-coverage) +- [🧰Technical Stack](#technical-stack) +- [✅Run Application](#run-application) +- [🐳Run Application With Docker](#run-application-with-docker) +- [📄Run E2E Test](#run-e2e-test) +- [📄Run Unit Test](#run-unit-test) +- [📝TODO](#todo) -

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

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - - Support us - -

- +## Diagram -## Description +![](img/diagram.png) -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +## Github Actions CI flow -## Installation +![](img/github-action-flow.png) -```bash -$ yarn install -``` +## Frontend Repository -## Running the app +- Author: Oseungkwon +- [Repository](https://github.com/OseungKwon/Online-Judge-System-Web) -```bash -# development -$ yarn run start +## Test Coverage -# watch mode -$ yarn run start:dev +- E2E Test: 88.12% +- Unit & Integration Test: 79.13% -# production mode -$ yarn run start:prod -``` +## Technical Stack -## Test +- Language + - TypeScript(Node.js v18 Runtime) +- Framework + - Nest.js +- ORM + - Prisma ORM +- Database(Persistence & Caching) + - MySQL 8.0 + - Redis + - AWS S3 +- Issue Tracking + - Sentry +- Proxy Server + - Nginx +- Infrastructure + - Docker & Docker-Compose + - AWS Elastic Beanstalk(EC2 Instance) + - Node.js Runtime x2 (Worker Server & Web Server) + - Docker Runtime x1 + - AWS Worker Communication + - AWS Auto Scaling Group + - AWS SQS: For worker server + - AWS S3: Build Versioning +- Test + - Jest + - Jest-Extended +- CI/CD + - Github Actions + - Code Pipeline & Code Build +- Alert + - Discord -```bash -# unit tests -$ yarn run test +## Run Application -# e2e tests -$ yarn run test:e2e +1. Git clone repository -# test coverage -$ yarn run test:cov -``` + ``` + git clone https://github.com/J-Hoplin/Online-Judge-System.git -## Support + cd Online-Judge-System + ``` -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). +2. Install dependencies -## Stay in touch + ``` + yarn install + ``` -- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +3. Run/Stop database with docker -## License + ``` + # Start + yarn db:dev:up + ``` -Nest is [MIT licensed](LICENSE). + ``` + # Stop + yarn db:dev:down + ``` + +4. Sync prisma schema to database + + ``` + yarn db:push + ``` + +5. Run application + + ``` + yarn dev + ``` + +## Run Application with docker + +1. Build docker image + + ``` + docker build -t online-judge . + ``` + +2. Run with docker enviornment + + ``` + yarn docker:up + ``` + +3. Remove docker environment + + ``` + yarn docker:down + ``` + +## Run E2E Test + +- Config: test/jest-e2e.json +- Mock Provider: test/mock.provider.ts + +1. Run database + + ``` + yarn db:dev:up + ``` + +2. Initialize test database + + ``` + yarn test:init + ``` + +3. Run E2E Test + + ``` + yarn test:e2e + ``` + +4. Run E2E Coverage Test + + ``` + yarn test:e2e:cov + ``` + +## Run Unit Test + +- Config: test/jest-unit.json +- Mock Provider: test/mock.provider.ts + +1. Run database + + ``` + yarn db:dev:up + ``` + +2. Initialize test database + + ``` + yarn test:init + ``` + +3. Run E2E Test + + ``` + yarn test:unit + ``` + +4. Run E2E Coverage Test + + ``` + yarn test:unit:cov + ``` + +## TODO + +- [ ] Apply Strategy Pattern to Asynchronous Worker + - Use Nest.js Custom Provider + - Rabbit MQ Strategy & AWS SQS Strategy +- [ ] Make Online Judge Server with Golang + - Now using [Judge0](https://judge0.com) based custom server diff --git a/architecture-diagram-20240124.drawio b/architecture-diagram-20240124.drawio new file mode 100644 index 0000000..f8937be --- /dev/null +++ b/architecture-diagram-20240124.drawio @@ -0,0 +1,340 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yaml b/docker-compose.yaml index 4536e6a..28efc2f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,15 +5,29 @@ services: ports: - '3306:3306' restart: 'unless-stopped' - # volumes: - # - system-db:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD=hoplin1234! - MYSQL_ROOT_HOST=% - MYSQL_DATABASE=judge + networks: + - system + api: + image: online-judge + ports: + - '3000:3000' + restart: 'unless-stopped' + env_file: + - ./.docker.env + depends_on: + - db + networks: + - system + redis: + image: redis + ports: + - '6379:6379' + networks: + - system networks: system: driver: bridge -# volumes: -# system-db: -# external: false diff --git a/domain/problem.domain.ts b/domain/problem.domain.ts index d4c89da..fd9d113 100644 --- a/domain/problem.domain.ts +++ b/domain/problem.domain.ts @@ -29,6 +29,9 @@ export class ProblemDomain implements Problem { }) tags: string[] | Prisma.JsonValue; + @ApiProperty() + isOpen: boolean; + @ApiProperty() isArchived: boolean; diff --git a/domain/user.domain.ts b/domain/user.domain.ts index f850d66..fd964e7 100644 --- a/domain/user.domain.ts +++ b/domain/user.domain.ts @@ -21,6 +21,11 @@ export class UserDomain implements User { }) message: string; + @ApiProperty({ + required: false, + }) + profileImage: string; + @ApiProperty({ required: false, }) diff --git a/img/diagram.png b/img/diagram.png new file mode 100644 index 0000000..242795b Binary files /dev/null and b/img/diagram.png differ diff --git a/img/github-action-flow.png b/img/github-action-flow.png new file mode 100644 index 0000000..80a3d87 Binary files /dev/null and b/img/github-action-flow.png differ diff --git a/libs/aws-s3/src/aws-s3.service.ts b/libs/aws-s3/src/aws-s3.service.ts index afdc6dd..9d522b8 100644 --- a/libs/aws-s3/src/aws-s3.service.ts +++ b/libs/aws-s3/src/aws-s3.service.ts @@ -22,6 +22,8 @@ import { v4 } from 'uuid'; * getSignedURL: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_s3_request_presigner.html * PutObjectCommand: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/classes/putobjectcommand.html * + * + * 2024 Hoplin */ @Injectable() @@ -43,20 +45,25 @@ export class AwsS3Service { } /** Upload file */ - public async uploadFile(file: Express.Multer.File, directory: string) { + public async uploadFile( + file: Express.Multer.File, + directory: string, + tag?: { [k: string]: string }, + ) { /** Generate file salt */ const fileSalt = v4(); /** File name with salt */ const fileKey = `${fileSalt}_${file.originalname}`; /** S3 upload location */ const s3SavedIn = this.directoryBuilder(fileKey, directory); + const tagQuery = tag ? new URLSearchParams(tag).toString() : ''; /** S3 Object put command */ const command = new PutObjectCommand({ Bucket: this.s3Bucket, Key: s3SavedIn, ContentType: file.mimetype, Body: file.buffer, - ACL: 'public-read', + Tagging: tagQuery, }); await this.s3Client.send(command); @@ -70,7 +77,7 @@ export class AwsS3Service { return null; } return `https://${process.env.AWS_S3_BUCKET}.s3.${ - process.env.AWS_S3_Region + process.env.AWS_REGION }.amazonaws.com/${this.directoryBuilder(fileKey, directory)}`; } diff --git a/libs/aws-sqs/src/aws-sqs.service.ts b/libs/aws-sqs/src/aws-sqs.service.ts index 477c1ce..7a92f36 100644 --- a/libs/aws-sqs/src/aws-sqs.service.ts +++ b/libs/aws-sqs/src/aws-sqs.service.ts @@ -1,7 +1,6 @@ import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'; import { Injectable } from '@nestjs/common'; import { SQSTask } from './type'; -import { take } from 'rxjs'; @Injectable() export class AwsSqsService { @@ -23,10 +22,14 @@ export class AwsSqsService { } async sendTask(task: SQSTask) { - const command = new SendMessageCommand({ - QueueUrl: this.sqsQueue, - MessageBody: JSON.stringify(task), - }); - await this.sqsClient.send(command); + // Do send task if it's dev or production + if (process.env.ENV === 'dev' || process.env.ENV === 'production') { + const command = new SendMessageCommand({ + QueueUrl: this.sqsQueue, + MessageBody: JSON.stringify(task), + }); + + await this.sqsClient.send(command); + } } } diff --git a/libs/aws-sqs/src/dto/index.ts b/libs/aws-sqs/src/dto/index.ts new file mode 100644 index 0000000..3379698 --- /dev/null +++ b/libs/aws-sqs/src/dto/index.ts @@ -0,0 +1 @@ +export * from './worker-dto'; diff --git a/libs/aws-sqs/src/dto/worker-dto.ts b/libs/aws-sqs/src/dto/worker-dto.ts new file mode 100644 index 0000000..e77e627 --- /dev/null +++ b/libs/aws-sqs/src/dto/worker-dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { SQSMessageType, SQSTask } from '../type'; + +export class WorkerDto implements SQSTask { + @ApiProperty() + @IsString() + @IsNotEmpty() + message: SQSMessageType; + + @ApiProperty() + @IsNotEmpty() + id: string | number; +} diff --git a/libs/aws-sqs/src/type.ts b/libs/aws-sqs/src/type.ts index a2f2340..9ec861e 100644 --- a/libs/aws-sqs/src/type.ts +++ b/libs/aws-sqs/src/type.ts @@ -1,4 +1,11 @@ +export enum SQSMessageTypeEnum { + RE_CORRECTION, + CODE_SUBMIT, +} + +export type SQSMessageType = keyof typeof SQSMessageTypeEnum; + export type SQSTask = { - message: string; - id: string; + message: SQSMessageType; + id: string | number; }; diff --git a/package.json b/package.json index a11fc06..c538931 100644 --- a/package.json +++ b/package.json @@ -10,22 +10,27 @@ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", "start": "node dist/main", "dev": "nest start --watch", - "db:dev:up": "docker-compose up -d", - "db:dev:down": "docker-compose down", + "db:dev:up": "docker-compose up -d db", + "db:dev:down": "docker-compose down db", + "db:generate": "npx prisma generate", "db:push": "prisma db push", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:start": "yarn db:push && yarn db:generate && yarn start", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest", "ci:init": "dotenv -e .ci.env -- npx prisma db push", - "ci:unit": "dotenv -e .ci.env -- jest --setupFiles jest --config ./test/jest-unit.json --runInBand", - "ci:e2e": "dotenv -e .ci.env -- jest --config ./test/jest-e2e.json --runInBand", + "ci:tearDown": "dotenv -e .ci.env -- npx prisma db push --force-reset", + "ci:unit": "dotenv -e .ci.env -- jest --setupFiles jest --config ./test/jest-unit.json", + "ci:e2e": "dotenv -e .ci.env -- jest --config ./test/jest-e2e.json", "test:init": "dotenv -e .test.env -- npx prisma db push", - "test:e2e": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json --runInBand", - "test:e2e:cov": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json --runInBand --coverage", - "test:unit": "dotenv -e .test.env -- jest --config ./test/jest-unit.json --runInBand", - "test:unit:cov": "dotenv -e .test.env -- jest --config ./test/jest-unit.json --runInBand --coverage" + "test:e2e": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json", + "test:e2e:cov": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json --coverage", + "test:unit": "dotenv -e .test.env -- jest --config ./test/jest-unit.json", + "test:unit:cov": "dotenv -e .test.env -- jest --config ./test/jest-unit.json --coverage" }, "dependencies": { "@aws-sdk/client-s3": "^3.465.0", @@ -39,6 +44,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.1.16", "@prisma/client": "^5.6.0", + "@sentry/node": "^7.93.0", + "@sentry/profiling-node": "^1.3.5", "@types/multer": "^1.4.11", "axios": "^1.6.2", "bcryptjs": "^2.4.3", @@ -70,6 +77,7 @@ "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "jest": "^29.5.0", + "jest-extended": "^4.0.2", "prettier": "^2.8.8", "source-map-support": "^0.5.21", "supertest": "^6.3.3", diff --git a/prisma/migrations/20240115050418_proble_is_open/migration.sql b/prisma/migrations/20240115050418_proble_is_open/migration.sql new file mode 100644 index 0000000..d66934f --- /dev/null +++ b/prisma/migrations/20240115050418_proble_is_open/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE `problem_issue_comment` DROP FOREIGN KEY `problem_issue_comment_problemId_fkey`; + +-- AlterTable +ALTER TABLE `problem` ADD COLUMN `isOpen` BOOLEAN NOT NULL DEFAULT false; + +-- AddForeignKey +ALTER TABLE `problem_issue_comment` ADD CONSTRAINT `problem_issue_comment_problemId_fkey` FOREIGN KEY (`problemId`) REFERENCES `problem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240117154221_user_profile_image/migration.sql b/prisma/migrations/20240117154221_user_profile_image/migration.sql new file mode 100644 index 0000000..e263476 --- /dev/null +++ b/prisma/migrations/20240117154221_user_profile_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `user` ADD COLUMN `profileImage` VARCHAR(191) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e82a493..5763244 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,19 +12,20 @@ datasource db { } model User { - id String @id @default(uuid()) - nickname String @unique - password String - email String @unique - message String? - github String? - blog String? - type UserType? @default(User) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - problems Problem[] - Submission Submission[] - Issues ProblemIssue[] + id String @id @default(uuid()) + nickname String @unique + password String + email String @unique + message String? + github String? + blog String? + profileImage String? + type UserType? @default(User) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + problems Problem[] + Submission Submission[] + Issues ProblemIssue[] IssueComments ProblemIssueComment[] @@map("user") @@ -53,10 +54,11 @@ model Problem { examples ProblemExample[] tags Json + isOpen Boolean @default(false) isArchived Boolean @default(false) deletedAt DateTime? - issues ProblemIssue[] + issues ProblemIssue[] comments ProblemIssueComment[] createdAt DateTime @default(now()) @@ -120,15 +122,15 @@ model Submission { } model ProblemIssue { - id Int @id @default(autoincrement()) - title String @db.VarChar(100) @default("Title Here") - content String @db.Text + id Int @id @default(autoincrement()) + title String @default("Title Here") @db.VarChar(100) + content String @db.Text // 1:N relation with Problem problemId Int problem Problem @relation(references: [id], fields: [problemId], onDelete: Cascade) // 1:N relation with User - issuerId String - issuer User @relation(references: [id], fields: [issuerId], onDelete: Cascade) + issuerId String + issuer User @relation(references: [id], fields: [issuerId], onDelete: Cascade) comments ProblemIssueComment[] @@ -136,17 +138,17 @@ model ProblemIssue { } model ProblemIssueComment { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) content String @db.Text issueId Int issue ProblemIssue @relation(fields: [issueId], references: [id], onDelete: Cascade, onUpdate: Cascade) - userId String - user User @relation(fields: [userId],references: [id],onDelete: Cascade, onUpdate: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) problemId Int - problem Problem @relation(references: [id],fields: [problemId]) + problem Problem @relation(references: [id], fields: [problemId]) @@map("problem_issue_comment") } diff --git a/src/admin-init.ts b/src/admin-init.ts index 1cf5b4a..ae7a58d 100644 --- a/src/admin-init.ts +++ b/src/admin-init.ts @@ -2,6 +2,7 @@ import { INestApplication, LoggerService } from '@nestjs/common'; import { PrismaService } from './prisma/prisma.service'; import { SystemLoggerService } from './system-logger/system-logger.service'; import * as bcrypt from 'bcryptjs'; +import { PrismaClient } from '@prisma/client'; export async function InitializeAdmin(app: INestApplication) { // Generate default admin account @@ -14,24 +15,26 @@ export async function InitializeAdmin(app: INestApplication) { ); throw new Error('Fail to initialize server'); } else { - const findAdmin = await prisma.user.findUnique({ - where: { - email: process.env.ADMIN_EMAIL, - }, - }); - if (!findAdmin) { - // Initialize root admin - await prisma.user.create({ - data: { - nickname: 'admin', - password: bcrypt.hashSync(process.env.ADMIN_PW, 10), - email: process.env.ADMIN_EMAIL, - type: 'Admin', - }, + // Initialize root admin + try { + await prisma.$transaction(async (tx: PrismaClient) => { + await tx.user.upsert({ + where: { + email: process.env.ADMIN_EMAIL, + }, + create: { + nickname: 'admin', + password: bcrypt.hashSync(process.env.ADMIN_PW, 10), + email: process.env.ADMIN_EMAIL, + type: 'Admin', + }, + update: {}, + }); + logger.log('Admin Initialized'); }); - logger.log('Admin initialized'); - } else { - logger.log('Admin already initialized'); + } catch (err) { + } finally { + return true; } } } diff --git a/src/app.module.ts b/src/app.module.ts index 382658c..d5c2209 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,8 @@ import { UserModule } from './user/user.module'; import { ConfigModule } from '@nestjs/config'; import { JudgeModule } from './judge/judge.module'; import { SystemLoggerModule } from './system-logger/system-logger.module'; +import { WorkerModule } from './worker/worker.module'; +import { ArtifactModule } from './artifact/artifact.module'; @Module({ imports: [ @@ -16,6 +18,8 @@ import { SystemLoggerModule } from './system-logger/system-logger.module'; PrismaModule, UserModule, JudgeModule, + WorkerModule, + ArtifactModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/artifact/artifact.controller.ts b/src/artifact/artifact.controller.ts new file mode 100644 index 0000000..a0f9deb --- /dev/null +++ b/src/artifact/artifact.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + FileNameTransformPipe, + FileOptionFactory, + StaticArtifactConfig, +} from 'app/config/artifact.config'; +import { ArtifactDocs } from './artifact.docs'; +import { ArtifactService } from './artifact.service'; +import { LocalGuard } from 'app/auth/guard'; + +@Controller('artifact') +@UseGuards(LocalGuard) +@ArtifactDocs.Controller() +export class ArtifactController { + constructor(private artifactService: ArtifactService) {} + + @Post('static') + @UseInterceptors( + FileInterceptor('artifact', FileOptionFactory(StaticArtifactConfig)), + ) + @ArtifactDocs.uploadStaticArtifact() + uploadStaticArtifact( + @UploadedFile(FileNameTransformPipe) file: Express.Multer.File, + ) { + return this.artifactService.uploadStaticArtifact(file); + } +} diff --git a/src/artifact/artifact.docs.ts b/src/artifact/artifact.docs.ts new file mode 100644 index 0000000..d012752 --- /dev/null +++ b/src/artifact/artifact.docs.ts @@ -0,0 +1,31 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { UploadStaticArtifactDto } from './dto'; +import { UploadStaticArtifactResponse } from './response'; + +export class ArtifactDocs { + public static Controller() { + return applyDecorators(ApiTags('Artifact'), ApiBearerAuth()); + } + public static uploadStaticArtifact() { + return applyDecorators( + ApiOperation({ + summary: 'Static File Upload. Public Artifact 업로드', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + type: UploadStaticArtifactDto, + }), + ApiCreatedResponse({ + type: UploadStaticArtifactResponse, + }), + ); + } +} diff --git a/src/artifact/artifact.module.ts b/src/artifact/artifact.module.ts new file mode 100644 index 0000000..e87ace8 --- /dev/null +++ b/src/artifact/artifact.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ArtifactController } from './artifact.controller'; +import { ArtifactService } from './artifact.service'; +import { AwsS3Module } from 's3/aws-s3'; + +@Module({ + imports: [AwsS3Module], + controllers: [ArtifactController], + providers: [ArtifactService], +}) +export class ArtifactModule {} diff --git a/src/artifact/artifact.service.ts b/src/artifact/artifact.service.ts new file mode 100644 index 0000000..d83ef2a --- /dev/null +++ b/src/artifact/artifact.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { StaticArtifactDir } from 'app/config'; +import { AwsS3Service } from 's3/aws-s3'; +import { UploadStaticArtifactResponse } from './response'; + +@Injectable() +export class ArtifactService { + constructor(private s3: AwsS3Service) {} + + async uploadStaticArtifact( + file: Express.Multer.File, + ): Promise { + try { + const fileKey = await this.s3.uploadFile(file, StaticArtifactDir, { + public: 'true', + }); + const url = this.s3.getStaticURL(fileKey, StaticArtifactDir); + return { + success: true, + url, + }; + } catch (err) { + return { + success: false, + url: '', + }; + } + } +} + +/** + * Bucket Policy + * + * Set tag "public=true" to set artifact public access + * + * https://repost.aws/knowledge-center/read-access-objects-s3-bucket + */ diff --git a/src/artifact/dto/index.ts b/src/artifact/dto/index.ts new file mode 100644 index 0000000..9fafeae --- /dev/null +++ b/src/artifact/dto/index.ts @@ -0,0 +1 @@ +export * from './upload-static-artifact.dto'; diff --git a/src/artifact/dto/upload-static-artifact.dto.ts b/src/artifact/dto/upload-static-artifact.dto.ts new file mode 100644 index 0000000..eb2fae5 --- /dev/null +++ b/src/artifact/dto/upload-static-artifact.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional } from 'class-validator'; + +export class UploadStaticArtifactDto { + @ApiProperty({ + type: 'string', + format: 'binary', + }) + @IsOptional() + artifact: Express.Multer.File; +} diff --git a/src/artifact/response/index.ts b/src/artifact/response/index.ts new file mode 100644 index 0000000..5bdf384 --- /dev/null +++ b/src/artifact/response/index.ts @@ -0,0 +1 @@ +export * from './upload-static-artifact.response'; diff --git a/src/artifact/response/upload-static-artifact.response.ts b/src/artifact/response/upload-static-artifact.response.ts new file mode 100644 index 0000000..28c5c84 --- /dev/null +++ b/src/artifact/response/upload-static-artifact.response.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UploadStaticArtifactResponse { + @ApiProperty() + success: boolean; + + @ApiProperty() + url: string; +} diff --git a/src/auth/dto/signin.dto.ts b/src/auth/dto/signin.dto.ts index eb90d52..0a43334 100644 --- a/src/auth/dto/signin.dto.ts +++ b/src/auth/dto/signin.dto.ts @@ -1,8 +1,10 @@ import { PickType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { UserDomain } from 'domains'; export class SigninDto extends PickType(UserDomain, ['email', 'password']) { + @Transform(({ value }) => value.trim().toLowerCase()) @IsEmail() @IsNotEmpty() email: string; diff --git a/src/auth/dto/signup.dto.ts b/src/auth/dto/signup.dto.ts index e135428..b16950f 100644 --- a/src/auth/dto/signup.dto.ts +++ b/src/auth/dto/signup.dto.ts @@ -1,4 +1,5 @@ import { PickType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { UserDomain } from 'domains'; @@ -7,6 +8,7 @@ export class SingupDto extends PickType(UserDomain, [ 'password', 'email', ]) { + @Transform(({ value }) => value.trim().toLowerCase()) @IsString() @IsNotEmpty() nickname: string; diff --git a/src/auth/guard/local.guard.ts b/src/auth/guard/local.guard.ts index 9241f5e..d1b5135 100644 --- a/src/auth/guard/local.guard.ts +++ b/src/auth/guard/local.guard.ts @@ -1,3 +1,28 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { AllowPublicToken } from 'app/decorator'; -export class LocalGuard extends AuthGuard('local') {} +// JWT Guard returns 401 default +@Injectable() +export class LocalGuard extends AuthGuard('local') { + constructor(private reflector: Reflector) { + super(); + } + + async canActivate(context: ExecutionContext) { + const allowPublic = this.reflector.get( + AllowPublicToken, + context.getHandler(), + ); + + if (allowPublic) { + try { + return (await super.canActivate(context)) as boolean; + } catch (err) { + return true; + } + } + return (await super.canActivate(context)) as boolean; + } +} diff --git a/src/auth/strategy/local.strategy.ts b/src/auth/strategy/local.strategy.ts index f6c7c72..d5ba4b4 100644 --- a/src/auth/strategy/local.strategy.ts +++ b/src/auth/strategy/local.strategy.ts @@ -20,6 +20,7 @@ export class LocalStrategy extends PassportStrategy(Strategy, 'local') { id, }, }); + if (!user) { throw new ForbiddenException('FORBIDDEN_REQUEST'); } diff --git a/src/config/artifact-directory.config.ts b/src/config/artifact-directory.config.ts new file mode 100644 index 0000000..5d75019 --- /dev/null +++ b/src/config/artifact-directory.config.ts @@ -0,0 +1,10 @@ +/** + * S3 Bucket Destination + * + * + * Follow this format: (root)/~~/(dir) + */ + +export const UserProfileImageDir = 'user/profile'; + +export const StaticArtifactDir = 'statics'; diff --git a/src/config/artifact.config.ts b/src/config/artifact.config.ts new file mode 100644 index 0000000..ee9e62a --- /dev/null +++ b/src/config/artifact.config.ts @@ -0,0 +1,89 @@ +import { + ArgumentMetadata, + Injectable, + PipeTransform, + UnprocessableEntityException, +} from '@nestjs/common'; +import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; +import { + ApiPayloadTooLargeResponse, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; + +// Interface type of artifact limit configuration +interface ArtifactValidationConfig { + size: number; + + extension: RegExp; +} + +// Util: MB unit to Byte +const mbt2bt = (mbt: number) => { + return mbt * 1024 * 1024; +}; + +/** + * Util FileNameTransformerPipe + * + * https://github.com/expressjs/multer/issues/1104 + * + * https://github.com/mscdex/busboy/issues/20 + * + * Multer use internally use 'BusBoy' package which is streaming parser of Form Data + * + * Clients should be sending non-latin1 header parameter values using the format (encoded words) defined by RFC5987. If they don't send that, then the values are assumed to be encoded as latin1 + */ +@Injectable() +export class FileNameTransformPipe implements PipeTransform { + transform(value: Express.Multer.File, metadata: ArgumentMetadata) { + if (value) { + value.originalname = Buffer.from(value.originalname, 'latin1').toString( + 'utf-8', + ); + return value; + } + return value; + } +} + +// File Option Factory +export const FileOptionFactory = ( + config: ArtifactValidationConfig, +): MulterOptions => { + return { + limits: { + fileSize: mbt2bt(config.size), + }, + fileFilter: (req, file, cb) => { + if (file.originalname.toLowerCase().match(config.extension)) { + cb(null, true); + } else { + cb(new UnprocessableEntityException('UNSUPPORTED_EXTENSION'), false); + } + }, + }; +}; + +// File Option Validation error swagger document +export const FileValidationErrorDocs = () => { + return [ + ApiPayloadTooLargeResponse({ + description: 'FILE_SIZE_LIMIT_EXCEED', + }), + ApiUnprocessableEntityResponse({ + description: 'UNSUPPORTED_EXTENSION', + }), + ]; +}; + +//Config + +export const UserProfileImageArtifactConfig: ArtifactValidationConfig = { + size: 10, + extension: /^.*\.(jpg|jpeg|png)$/, +}; + +export const StaticArtifactConfig: ArtifactValidationConfig = { + size: 10, + extension: /^.*\.[a-zA-Z0-9]+$/, +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..8dd9253 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './artifact-directory.config'; +export * from './artifact.config'; diff --git a/src/decorator/index.ts b/src/decorator/index.ts index dfa6dab..c19a9a1 100644 --- a/src/decorator/index.ts +++ b/src/decorator/index.ts @@ -1,3 +1,4 @@ export * from './get-user.decorator'; export * from './pagination.decorator'; export * from './role.decorator'; +export * from './public.decorator'; diff --git a/src/decorator/multiple-definition.decorator.ts b/src/decorator/multiple-definition.decorator.ts new file mode 100644 index 0000000..24d6dfd --- /dev/null +++ b/src/decorator/multiple-definition.decorator.ts @@ -0,0 +1,80 @@ +import { HttpStatus, Type, applyDecorators } from '@nestjs/common'; +import { + ApiBody, + ApiExtraModels, + ApiResponse, + getSchemaPath, +} from '@nestjs/swagger'; + +interface ResponseReference { + classRef: Type; + example: any; + isArray?: boolean; + description?: string; +} + +export type MultipleResponseOptions = Record; + +// Use when Swagger requires to show multiple response +export const ApiMultipleResponse = ( + statusCode: HttpStatus | number, + options: MultipleResponseOptions, +) => { + const models = Object.values(options).map((option) => { + return option.classRef; + }); + + const responseExample = {}; + for (const [key, option] of Object.entries(options)) { + responseExample[key] = { + value: option.isArray ? [option.example] : option.example, + }; + } + + return applyDecorators( + ApiExtraModels(...models), + ApiResponse({ + status: statusCode, + content: { + 'application/json': { + schema: { + oneOf: models.map((model) => { + return { + $ref: getSchemaPath(model), + }; + }), + }, + examples: responseExample, + }, + }, + }), + ); +}; + +// Use when Swagger requires to show multiple body +export const ApiMultipleBody = (options: MultipleResponseOptions) => { + const models = Object.values(options).map((option) => { + return option.classRef; + }); + + const responseExample = {}; + for (const [key, option] of Object.entries(options)) { + responseExample[key] = { + value: option.isArray ? [option.example] : option.example, + }; + } + + return applyDecorators( + ApiExtraModels(...models), + ApiBody({ + schema: { + oneOf: models.map((model) => { + return { + $ref: getSchemaPath(model), + }; + }), + }, + examples: responseExample, + }), + ); +}; diff --git a/src/decorator/public.decorator.ts b/src/decorator/public.decorator.ts new file mode 100644 index 0000000..f7bb90f --- /dev/null +++ b/src/decorator/public.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * Set metadata as allow-public true + * + */ +export const AllowPublicToken = 'allow-public'; +export const AllowPublic = () => SetMetadata(AllowPublicToken, true); diff --git a/src/filter/index.ts b/src/filter/index.ts new file mode 100644 index 0000000..ba11ca9 --- /dev/null +++ b/src/filter/index.ts @@ -0,0 +1 @@ +export * from './sentry.filter'; diff --git a/src/filter/sentry.filter.ts b/src/filter/sentry.filter.ts new file mode 100644 index 0000000..185d0f3 --- /dev/null +++ b/src/filter/sentry.filter.ts @@ -0,0 +1,11 @@ +import { ArgumentsHost, Catch } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import * as Sentry from '@sentry/node'; + +@Catch() +export class SentryFilter extends BaseExceptionFilter { + catch(exception: any, host: ArgumentsHost): void { + Sentry.captureException(exception); + super.catch(exception, host); + } +} diff --git a/src/guard/role.guard.ts b/src/guard/role.guard.ts index 0c45367..14dd5a6 100644 --- a/src/guard/role.guard.ts +++ b/src/guard/role.guard.ts @@ -14,6 +14,7 @@ import { Request } from 'express'; @Injectable() export class RoleGuard implements CanActivate { + // Throws Forbidden(403) constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const roleList = this.reflector.getAllAndOverride(Role, [ diff --git a/src/judge/contributer/contributer.controller.e2e-spec.ts b/src/judge/contributer/contributer.controller.e2e-spec.ts new file mode 100644 index 0000000..fb9bb6c --- /dev/null +++ b/src/judge/contributer/contributer.controller.e2e-spec.ts @@ -0,0 +1,321 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { InitializeAdmin } from 'app/admin-init'; +import { AppModule } from 'app/app.module'; +import { PrismaService } from 'app/prisma/prisma.service'; +import { userSignupGen } from 'test/mock-generator'; +import * as request from 'supertest'; +import { BearerTokenHeader } from 'test/test-utils'; + +describe('/judge/contributer', () => { + let app: INestApplication; + let prisma: PrismaService; + + const user1 = userSignupGen(); + let user1Token: string; + const user2 = userSignupGen(); + let user2Token: string; + let user2Id: string; + let adminToken: string; + let problemId: string; + let exampleId: number; + //endpoints + + beforeAll(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = testModule.createNestApplication(); + prisma = testModule.get(PrismaService); + + await InitializeAdmin(app); + await app.init(); + }); + + afterAll(async () => { + await prisma.deleteAll(); + await app.close(); + }); + + // Create User + describe('/signup POST', () => { + it('should create user1', async () => { + const signup = await request(app.getHttpServer()) + .post('/auth/signup') + .send(user1); + expect(signup.statusCode).toBe(200); + expect(signup.body).not.toBeUndefined(); + user1Token = signup.body['accessToken']; + }); + + it('should create user2', async () => { + const signup = await request(app.getHttpServer()) + .post('/auth/signup') + .send(user2); + + expect(signup.statusCode).toBe(200); + expect(signup.body).not.toBeUndefined(); + user2Token = signup.body['accessToken']; + }); + + it('should get user2 id', async () => { + const getProfile = await request(app.getHttpServer()) + .get('/user/profile') + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(200); + user2Id = getProfile.body['id']; + }); + + it('should signup admin', async () => { + const signin = await request(app.getHttpServer()) + .post('/auth/signin') + .send({ + password: process.env.ADMIN_PW, + email: process.env.ADMIN_EMAIL, + }); + + expect(signin.statusCode).toBe(200); + expect(signin.body).not.toBeUndefined(); + adminToken = signin.body['accessToken']; + }); + + it('should change user2 as contributer', () => { + return request(app.getHttpServer()) + .patch('/user/admin/role') + .set('Authorization', BearerTokenHeader(adminToken)) + .send({ + role: 'Contributer', + targetId: user2Id, + }) + .expect(200); + }); + }); + + // Test + + describe('/problems GET', () => { + it('should throw if unauthenticated', async () => { + return request(app.getHttpServer()) + .get('/judge/contribute/problems') + .expect(401); + }); + it('should throw if unauthorized', async () => { + return request(app.getHttpServer()) + .get('/judge/contribute/problems') + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + it('should get contributer problem list', () => { + return request(app.getHttpServer()) + .get('/judge/contribute/problems') + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(200); + }); + }); + + describe('/problems POST', () => { + it('should throw if unauthentciated', async () => { + return request(app.getHttpServer()) + .post('/judge/contribute/problems') + .expect(401); + }); + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .post('/judge/contribute/problems') + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + it('should create new problem', async () => { + const response = await request(app.getHttpServer()) + .post('/judge/contribute/problems') + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(201); + problemId = response.body['id']; + }); + }); + + describe('/problems/:pid GET', () => { + it('should throw if unauthenticated', async () => { + return request(app.getHttpServer()) + .get(`/judge/contribute/problems/${problemId}`) + .expect(401); + }); + it('should throw if unauthorized', async () => { + return request(app.getHttpServer()) + .get(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + it('should throw if others contributer tries to access problem', () => { + return request(app.getHttpServer()) + .get(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + it('should read problem', () => { + return request(app.getHttpServer()) + .get(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(200); + }); + }); + + describe('/problems/:pid PATCH', () => { + it('should throw if unauthenticated', async () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}`) + .expect(401); + }); + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + it('should throw if other contributer tries to modify problem', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + it('should patch problem', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .send({ + title: 'string', + problem: 'string', + input: 'string', + output: 'string', + timeLimit: 0, + memoryLimit: 0, + tags: ['sort', 'bfs', 'math'], + isOpen: true, + }) + .expect(200); + }); + }); + + describe('/problmes/:pid/examples POST', () => { + it('should throw if unauthenticated', () => { + return request(app.getHttpServer()) + .post(`/judge/contribute/problems/${problemId}/examples`) + .expect(401); + }); + + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .post(`/judge/contribute/problems/${problemId}/examples`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + + it('should throw if other contributer tries to create example', () => { + return request(app.getHttpServer()) + .post(`/judge/contribute/problems/${problemId}/examples`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + + it('should create example for question', async () => { + const response = await request(app.getHttpServer()) + .post(`/judge/contribute/problems/${problemId}/examples`) + .set(`Authorization`, BearerTokenHeader(adminToken)) + .send({ + input: 'string', + output: 'string', + isPublic: true, + }) + .expect(201); + exampleId = response.body['id']; + }); + }); + + describe('/problems/:pid/examples/:eid PATCH', () => { + it('should throw if unauthenticated', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .expect(401); + }); + + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + + it('should throw if other contributer tries to modify exmaple', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + + it('should modify example', () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(200); + }); + }); + + describe('/problem/:pid/examples/:eid DELETE', () => { + it('should throw if unauthenticated', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .expect(401); + }); + + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + + it('should throw if other contributer tries to modify', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + + it('should throw if other contributer tries to modify', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}/examples/${exampleId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(200); + }); + }); + + describe('/problem/:pid DELETE', () => { + it('should throw if unauthenticated', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}`) + .expect(401); + }); + + it('should throw if unauthorized', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user1Token)) + .expect(403); + }); + + it('should throw if other contributer tries to modify', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(user2Token)) + .expect(403); + }); + + it('should throw if other contributer tries to modify', () => { + return request(app.getHttpServer()) + .delete(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .expect(200); + }); + }); +}); diff --git a/src/judge/contributer/contributer.controller.ts b/src/judge/contributer/contributer.controller.ts index 355248e..b8a361e 100644 --- a/src/judge/contributer/contributer.controller.ts +++ b/src/judge/contributer/contributer.controller.ts @@ -17,7 +17,7 @@ import { RoleGuard } from 'app/guard'; import { UpdateProblmeDto } from 'app/judge/contributer/dto/update-problem.dto'; import { ContributerDocs } from './contributer.docs'; import { ContributerService } from './contributer.service'; -import { UpdateExampleDto } from './dto'; +import { CreateExampleDto, UpdateExampleDto } from './dto'; import { ContributerProblemGuard } from './decorator/contributer-problem.guard'; @Controller() @@ -43,7 +43,7 @@ export class ContributerController { @ContributerDocs.readProblem() readProblem( @GetUser('id') uid: string, - @Query('pid', ParseIntPipe) pid: number, + @Param('pid', ParseIntPipe) pid: number, ) { return this.contributerService.readProblem(uid, pid); } @@ -81,8 +81,9 @@ export class ContributerController { createExample( @GetUser('id') uid: string, @Param('pid', ParseIntPipe) pid: number, + @Body() dto: CreateExampleDto, ) { - return this.contributerService.createExmaple(uid, pid); + return this.contributerService.createExmaple(uid, pid, dto); } @Patch('problems/:pid/examples/:eid') diff --git a/src/judge/contributer/contributer.module.ts b/src/judge/contributer/contributer.module.ts index 2f2f8bc..761b439 100644 --- a/src/judge/contributer/contributer.module.ts +++ b/src/judge/contributer/contributer.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ContributerService } from './contributer.service'; import { ContributerController } from './contributer.controller'; +import { AwsSqsModule } from 'aws-sqs/aws-sqs'; @Module({ + imports: [AwsSqsModule], providers: [ContributerService], controllers: [ContributerController], }) diff --git a/src/judge/contributer/contributer.service.spec.ts b/src/judge/contributer/contributer.service.spec.ts index 1c3fee0..1e9bf6e 100644 --- a/src/judge/contributer/contributer.service.spec.ts +++ b/src/judge/contributer/contributer.service.spec.ts @@ -5,6 +5,8 @@ import { PrismaService } from 'app/prisma/prisma.service'; import { ProblemDomain, ProblemExampleDomain, UserDomain } from 'domains'; import { userSignupGen } from 'test/mock-generator'; import { ForbiddenException } from '@nestjs/common'; +import { AwsSqsModule, AwsSqsService } from 'aws-sqs/aws-sqs'; +import { AwsSQSLibraryMockProvider } from 'test/mock.provider'; describe('ContributerService', () => { let service: ContributerService; @@ -23,9 +25,12 @@ describe('ContributerService', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [PrismaModule], + imports: [PrismaModule, AwsSqsModule], providers: [ContributerService], - }).compile(); + }) + .overrideProvider(AwsSqsService) + .useValue(AwsSQSLibraryMockProvider.useValue) + .compile(); // Initialize module for prisma service module.init(); @@ -58,7 +63,11 @@ describe('ContributerService', () => { }); it('should create new example of problem1', async () => { // Create example of problem1 - example1 = await service.createExmaple(user1.id, problem1.id); + example1 = await service.createExmaple(user1.id, problem1.id, { + input: '2 3 4', + output: '5 6 7', + isPublic: true, + }); expect(example1).toBeTruthy(); }); }); @@ -70,6 +79,7 @@ describe('ContributerService', () => { problem: 'Problem', input: 'Input', output: 'Output', + isOpen: true, timeLimit: 10, memoryLimit: 10, tags: ['string'], diff --git a/src/judge/contributer/contributer.service.ts b/src/judge/contributer/contributer.service.ts index 4c92fec..d399aec 100644 --- a/src/judge/contributer/contributer.service.ts +++ b/src/judge/contributer/contributer.service.ts @@ -2,11 +2,12 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { PaginateObject } from 'app/decorator'; import { UpdateProblmeDto } from 'app/judge/contributer/dto/update-problem.dto'; import { PrismaService } from 'app/prisma/prisma.service'; -import { UpdateExampleDto } from './dto'; +import { CreateExampleDto, UpdateExampleDto } from './dto'; +import { AwsSqsService } from 'aws-sqs/aws-sqs'; @Injectable() export class ContributerService { - constructor(private prisma: PrismaService) {} + constructor(private prisma: PrismaService, private sqs: AwsSqsService) {} async listProblem(uid: string, search: string, pagination: PaginateObject) { return this.prisma.problem.findMany({ @@ -48,13 +49,6 @@ export class ContributerService { } async updateProblem(uid: string, pid: number, dto: UpdateProblmeDto) { - const findProblem = await this.prisma.problem.findUnique({ - where: { - id: pid, - contributerId: uid, - }, - }); - // If time limit is lower than 0 if (dto?.timeLimit && dto.timeLimit < 0) { dto.timeLimit = 5; @@ -97,9 +91,10 @@ export class ContributerService { return updatedProblem; } - async createExmaple(uid: string, pid: number) { + async createExmaple(uid: string, pid: number, dto: CreateExampleDto) { return this.prisma.problemExample.create({ data: { + ...dto, problemId: pid, }, }); @@ -111,7 +106,7 @@ export class ContributerService { eid: number, dto: UpdateExampleDto, ) { - const findExample = await this.prisma.problemExample.findUnique({ + const previousExample = await this.prisma.problemExample.findUnique({ where: { id: eid, problemId: pid, @@ -120,9 +115,23 @@ export class ContributerService { }, }, }); - if (!findExample) { + + if (!previousExample) { throw new ForbiddenException('FORBIDDEN_REQUEST'); } + + // Only trigger re-correction if it input or out-put modified + if ( + dto.input !== previousExample.input || + dto.output !== previousExample.output + ) { + // Send task to client + await this.sqs.sendTask({ + id: pid, + message: 'RE_CORRECTION', + }); + } + return this.prisma.problemExample.update({ where: { id: eid, diff --git a/src/judge/contributer/decorator/contributer-problem.guard.ts b/src/judge/contributer/decorator/contributer-problem.guard.ts index 2e318c5..530f059 100644 --- a/src/judge/contributer/decorator/contributer-problem.guard.ts +++ b/src/judge/contributer/decorator/contributer-problem.guard.ts @@ -1,9 +1,7 @@ import { - BadRequestException, CanActivate, ExecutionContext, ForbiddenException, - Inject, Injectable, } from '@nestjs/common'; import { PrismaService } from 'app/prisma/prisma.service'; @@ -19,7 +17,8 @@ import { Request } from 'express'; @Injectable() export class ContributerProblemGuard implements CanActivate { - constructor(@Inject(PrismaService) private prisma: PrismaService) {} + constructor(private prisma: PrismaService) {} + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -37,7 +36,10 @@ export class ContributerProblemGuard implements CanActivate { }); return true; } catch (err) { - throw new ForbiddenException('FORBIDDEN_REQUEST'); + if (err.code === 'P2025') { + throw new ForbiddenException('FORBIDDEN_REQUEST'); + } + throw err; } } } diff --git a/src/judge/contributer/dto/create-example.dto.ts b/src/judge/contributer/dto/create-example.dto.ts new file mode 100644 index 0000000..75d001f --- /dev/null +++ b/src/judge/contributer/dto/create-example.dto.ts @@ -0,0 +1,20 @@ +import { OmitType } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean } from 'class-validator'; +import { ProblemExampleDomain } from 'domains'; + +export class CreateExampleDto extends OmitType(ProblemExampleDomain, [ + 'id', + 'problemId', +]) { + @IsString() + @IsOptional() + input: string; + + @IsString() + @IsOptional() + output: string; + + @IsBoolean() + @IsOptional() + isPublic: boolean; +} diff --git a/src/judge/contributer/dto/index.ts b/src/judge/contributer/dto/index.ts index 7f3730d..8ba1e82 100644 --- a/src/judge/contributer/dto/index.ts +++ b/src/judge/contributer/dto/index.ts @@ -1,2 +1,3 @@ export * from './update-problem.dto'; export * from './update-example.dto'; +export * from './create-example.dto'; diff --git a/src/judge/contributer/dto/update-example.dto.ts b/src/judge/contributer/dto/update-example.dto.ts index 11bf305..26ab14b 100644 --- a/src/judge/contributer/dto/update-example.dto.ts +++ b/src/judge/contributer/dto/update-example.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { OmitType } from '@nestjs/swagger'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { ProblemExampleDomain } from 'domains'; diff --git a/src/judge/contributer/dto/update-problem.dto.ts b/src/judge/contributer/dto/update-problem.dto.ts index 99e5862..bc23a6e 100644 --- a/src/judge/contributer/dto/update-problem.dto.ts +++ b/src/judge/contributer/dto/update-problem.dto.ts @@ -1,6 +1,7 @@ import { OmitType } from '@nestjs/swagger'; import { IsArray, + IsBoolean, IsNotEmpty, IsNumber, IsOptional, @@ -24,6 +25,10 @@ export class UpdateProblmeDto extends OmitType(ProblemDomain, [ @IsOptional() problem: string; + @IsBoolean() + @IsOptional() + isOpen: boolean; + @IsString() @IsOptional() input: string; diff --git a/src/judge/decorator/problem.guard.ts b/src/judge/decorator/problem.guard.ts index b33697e..7ac78a1 100644 --- a/src/judge/decorator/problem.guard.ts +++ b/src/judge/decorator/problem.guard.ts @@ -22,12 +22,12 @@ export class ProblemGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const problemId = request.params['pid']; - // Check if problem in DB try { await this.prisma.problem.findUniqueOrThrow({ where: { id: parseInt(problemId), + isOpen: true, }, }); return true; diff --git a/src/judge/judge.controller.e2e-spec.ts b/src/judge/judge.controller.e2e-spec.ts index 524547e..2bde23b 100644 --- a/src/judge/judge.controller.e2e-spec.ts +++ b/src/judge/judge.controller.e2e-spec.ts @@ -3,8 +3,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { InitializeAdmin } from 'app/admin-init'; import { AppModule } from 'app/app.module'; import { PrismaService } from 'app/prisma/prisma.service'; +import { Judge0Service } from 'judge/judge0'; import * as request from 'supertest'; import { userSignupGen } from 'test/mock-generator'; +import { JudgeLibraryMockProvider } from 'test/mock.provider'; import { BearerTokenHeader } from 'test/test-utils'; describe('/judge Judge Controller', () => { @@ -27,7 +29,10 @@ describe('/judge Judge Controller', () => { beforeAll(async () => { const testModule: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(Judge0Service) + .useValue(JudgeLibraryMockProvider.useValue) + .compile(); app = testModule.createNestApplication(); prisma = testModule.get(PrismaService); @@ -82,6 +87,16 @@ describe('/judge Judge Controller', () => { problemId = newProblem.body['id']; }); + it('should set problem public', async () => { + return request(app.getHttpServer()) + .patch(`/judge/contribute/problems/${problemId}`) + .set('Authorization', BearerTokenHeader(adminToken)) + .send({ + isOpen: true, + }) + .expect(200); + }); + it('should generate problme example', async () => { const example = await request(app.getHttpServer()) .post(`/judge/contribute/problems/${problemId}/examples`) @@ -106,7 +121,7 @@ describe('/judge Judge Controller', () => { // Test describe('/languages GET', () => { it('should throw if unauthenticated', async () => { - return request(app.getHttpServer()).get('/judge').expect(401); + return request(app.getHttpServer()).get('/judge/languages').expect(401); }); it('should get language list', async () => { return request(app.getHttpServer()) @@ -117,8 +132,8 @@ describe('/judge Judge Controller', () => { }); describe('/ GET', () => { - it('should throw if unauthenticated', async () => { - return request(app.getHttpServer()).get('/judge').expect(401); + it('should allow unauthenticated', async () => { + return request(app.getHttpServer()).get('/judge').expect(200); }); it('should get problem list', async () => { return request(app.getHttpServer()) @@ -129,10 +144,10 @@ describe('/judge Judge Controller', () => { }); describe('/:pid GET', () => { - it('should throw if unauthenticated', async () => { + it('should allow unauthenticated', async () => { return request(app.getHttpServer()) .get(`/judge/${problemId}`) - .expect(401); + .expect(200); }); it('should get problem info', async () => { return request(app.getHttpServer()) diff --git a/src/judge/judge.controller.ts b/src/judge/judge.controller.ts index dcf4ae0..d91ae93 100644 --- a/src/judge/judge.controller.ts +++ b/src/judge/judge.controller.ts @@ -8,10 +8,16 @@ import { ParseIntPipe, Patch, Post, + Request, UseGuards, } from '@nestjs/common'; import { LocalGuard } from 'app/auth/guard'; -import { GetUser, PaginateObject, Pagination } from 'app/decorator'; +import { + AllowPublic, + GetUser, + PaginateObject, + Pagination, +} from 'app/decorator'; import { JudgeFilter, JudgeFilterObject, @@ -44,19 +50,25 @@ export class JudgeController { } @Get('/') + @AllowPublic() @JudgeDocs.ListProblem() listProblem( @JudgeFilter() filter: JudgeFilterObject, @Pagination() paginate: PaginateObject, + @Request() req: Request, ) { - return this.judgeService.listProblem(filter, paginate); + return this.judgeService.listProblem(filter, paginate, req); } @Get('/:pid') + @AllowPublic() @UseGuards(ProblemGuard) @JudgeDocs.ReadProblem() - readProblem(@Param('pid', ParseIntPipe) pid: number) { - return this.judgeService.readProblem(pid); + readProblem( + @Param('pid', ParseIntPipe) pid: number, + @Request() req: Request, + ) { + return this.judgeService.readProblem(pid, req); } @Post('/:pid/run') diff --git a/src/judge/judge.docs.ts b/src/judge/judge.docs.ts index 2114e3d..0ea4cd8 100644 --- a/src/judge/judge.docs.ts +++ b/src/judge/judge.docs.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { HttpStatus, applyDecorators } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -10,6 +10,7 @@ import { } from '@nestjs/swagger'; import { PaginationDocs } from 'app/decorator'; +import { ApiMultipleResponse } from 'app/decorator/multiple-definition.decorator'; import { ProblemIssueDomain, SubmissionDomain } from 'domains'; import { SubmissionFilterDocs } from './decorator/submission-filter.decorator'; import { @@ -17,10 +18,12 @@ import { DeleteProblemIssueCommentResponse, DeleteProblemIssueResponse, GetLanguagesResponse, + ListProblemAuthenticatedResponse, ListProblemIssueResponse, - ListProblemResponse, + ListProblemUnAuthenticatedResponse, ListUserSubmissionRepsonse, - ReadProblemResponse, + ReadProblemAuthenticatedResponse, + ReadProblemUnauthenticatedResponse, ReadPublicSubmissionResponse, RunProblemResponse, SubmitProblemResponse, @@ -41,16 +44,109 @@ export class JudgeDocs { public static ListProblem() { return applyDecorators( ApiOperation({ summary: '문제 리스트 출력' }), - ApiOkResponse({ type: ListProblemResponse, isArray: true }), + ApiMultipleResponse(HttpStatus.OK, { + Authenticated: { + classRef: ListProblemUnAuthenticatedResponse, + example: { + id: 11, + title: 'New Problem', + contributer: { + nickname: 'admin', + }, + correct: 1, + total: 1, + correctionRate: '1.000', + status: 'SUCCESS', + }, + isArray: true, + description: 'If authenticated response', + }, + UnAuthenticated: { + classRef: ListProblemAuthenticatedResponse, + example: { + id: 10, + title: 'string', + contributer: { + nickname: 'admin', + }, + correct: 0, + total: 1, + correctionRate: '0.000', + }, + isArray: true, + description: 'If Unauthenticated Response', + }, + }), + ...PaginationDocs, ); } public static ReadProblem() { return applyDecorators( - ApiOperation({ summary: '문제 반환' }), - ApiOkResponse({ - type: ReadProblemResponse, + ApiOperation({ + summary: + '문제 반환. 비로그인 사용 가능 API. 비로그인 사용하는 경우에는 `isSuccess` 필드가 없습니다. Enum은 Response Schema 참고바랍니다.', }), + ApiMultipleResponse(HttpStatus.OK, { + Authenticated: { + classRef: ReadProblemAuthenticatedResponse, + example: { + id: 11, + title: 'New Problem', + problem: 'Problem Here', + input: 'Input Here', + output: 'Output Here', + timeLimit: 5, + memoryLimit: 128, + contributerId: '97f16592-93a3-4bba-9bc5-08f55c860bd4', + tags: [], + isOpen: true, + isArchived: false, + deletedAt: null, + createdAt: '2024-01-16T14:12:07.748Z', + updatedAt: '2024-01-16T14:12:39.185Z', + examples: [ + { + id: 6, + input: '', + output: 'hello world', + isPublic: true, + problemId: 11, + }, + ], + isSuccess: 'SUCCESS', + }, + }, + UnAuthenticated: { + classRef: ReadProblemUnauthenticatedResponse, + example: { + id: 11, + title: 'New Problem', + problem: 'Problem Here', + input: 'Input Here', + output: 'Output Here', + timeLimit: 5, + memoryLimit: 128, + contributerId: '97f16592-93a3-4bba-9bc5-08f55c860bd4', + tags: [], + isOpen: true, + isArchived: false, + deletedAt: null, + createdAt: '2024-01-16T14:12:07.748Z', + updatedAt: '2024-01-16T14:12:39.185Z', + examples: [ + { + id: 6, + input: '', + output: 'hello world', + isPublic: true, + problemId: 11, + }, + ], + }, + }, + }), + ApiNotFoundResponse({ description: ['PROBLEM_NOT_FOUND'].join(', ') }), ); } diff --git a/src/judge/judge.service.spec.ts b/src/judge/judge.service.spec.ts index 9e1a6b0..9f0112b 100644 --- a/src/judge/judge.service.spec.ts +++ b/src/judge/judge.service.spec.ts @@ -3,31 +3,701 @@ import { JudgeService } from './judge.service'; import { userSignupGen } from 'test/mock-generator'; import { Test, TestingModule } from '@nestjs/testing'; import { PrismaModule } from 'app/prisma/prisma.module'; -import { Judge0Module } from 'judge/judge0'; +import { Judge0Module, Judge0Service } from 'judge/judge0'; +import { JudgeLibraryMockProvider } from 'test/mock.provider'; +import { + ProblemDomain, + ProblemIssueCommentDomain, + ProblemIssueDomain, + SubmissionDomain, + UserDomain, +} from 'domains'; +import { JudgeFilterObject } from './decorator/judge-filter.decorator'; +import { PaginateObject } from 'app/decorator'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { + CreateProblemIssueCommentDto, + CreateProblemIssueDto, + SubmitProblemDto, +} from './dto'; +import { SubmissionFilterObject } from './decorator/submission-filter.decorator'; +import { ProblemStatus } from 'app/type'; describe('JudgeService', () => { let service: JudgeService; let prisma: PrismaService; - const user1 = userSignupGen(); - const user2 = userSignupGen(); + let user1: UserDomain; + const user1Signup = userSignupGen(true); + + let user2: UserDomain; + const user2Signup = userSignupGen(true); + + // Opened Problem + let problem1: ProblemDomain; + + // Closed Problem + let problem2: ProblemDomain; + + let problem3: ProblemDomain; + + let openSubmission: SubmissionDomain; + let closedSubmission: SubmissionDomain; + + let issue: ProblemIssueDomain; + let comment: ProblemIssueCommentDomain; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [PrismaModule, Judge0Module], providers: [JudgeService], - }).compile(); + }) + .overrideProvider(Judge0Service) + .useValue(JudgeLibraryMockProvider.useValue) + .compile(); service = module.get(JudgeService); prisma = module.get(PrismaService); + + await module.init(); + + user1 = await prisma.user.create({ + data: { + ...user1Signup, + }, + }); + + user2 = await prisma.user.create({ + data: { + ...user2Signup, + }, + }); + + problem1 = await prisma.problem.create({ + data: { + title: 'Have Example', + contributerId: user1.id, + tags: [], + isOpen: true, + }, + }); + + await prisma.problemExample.create({ + data: { + problemId: problem1.id, + input: 'input', + output: 'output', + }, + }); + + problem2 = await prisma.problem.create({ + data: { + title: 'No Example', + contributerId: user1.id, + tags: [], + isOpen: true, + }, + }); + + problem3 = await prisma.problem.create({ + data: { + title: 'Closed Problem', + contributerId: user1.id, + tags: [], + isOpen: false, + }, + }); }); + afterAll(async () => { await prisma.deleteAll(); }); - describe('Test', () => { - it('Test', () => { - expect(true); + // Class + describe('JudgeService', () => { + // Method + describe('getLanguages()', () => { + it('should return supported language', async () => { + //given + //when + const language = await service.getLanguages(); + //then + expect(language).toBeArray(); + }); + }); + + describe('listProblem()', () => { + it('should read problem if not authenticated', async () => { + //given + const filter: JudgeFilterObject = { + Orderby: {}, + Where: { + title: { + contains: 'Have', + }, + }, + }; + const paginate: PaginateObject = { + skip: 0, + take: 10, + }; + //when + const problems = await service.listProblem(filter, paginate, {}); + // Then + expect(problems[0]).not.toHaveProperty('isSuccess'); + expect(problems).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: problem3.id, + }), + ]), + ); + expect(problems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: problem1.id, + }), + ]), + ); + }); + + it('should read problem if authenticated', async () => { + //given + const filter: JudgeFilterObject = { + Orderby: {}, + Where: { + title: { + contains: 'Have', + }, + }, + }; + const paginate: PaginateObject = { + skip: 0, + take: 10, + }; + //when + const problems = await service.listProblem(filter, paginate, { + user: user1, + }); + // Then + expect(problems[0]).toHaveProperty('isSuccess'); + expect(problems).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: problem3.id, + }), + ]), + ); + expect(problems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: problem1.id, + }), + ]), + ); + }); + }); + + describe('readProblem()', () => { + it('should allow not authenticated', async () => { + // given + const id = problem1['id']; + // when + const problem = await service.readProblem(id, undefined); + // then + expect(problem.id).toBe(id); + expect(problem).not.toHaveProperty('isSuccess'); + }); + + it('should read problem if authenticated', async () => { + // given + const id = problem1['id']; + // when + const problem = await service.readProblem(id, { + user: user1, + }); + // then + expect(problem.id).toBe(id); + expect(problem).toHaveProperty('isSuccess'); + }); + }); + + describe('runProblem()', () => { + it('should run existing problem', async () => { + //given + const id = problem1['id']; + //when + const result = await service.runProblem(id, { + code: 'code', + languageId: 71, + }); + //then + expect(result).not.toBeUndefined(); + }); + + it('should throw if example not exist', async () => { + //given + const id = problem2['id']; + //when + try { + await service.runProblem(id, { + code: 'code', + languageId: 71, + }); + } catch (err) { + //then + expect(err).toBeInstanceOf(BadRequestException); + } + }); + }); + + describe('submitProblem()', () => { + it('should submit problem. Opened Submission', async () => { + // given + const userId = user1['id']; + const problemId = problem1['id']; + const dto: SubmitProblemDto = { + code: '', + isPublic: true, + languageId: 0, + language: '', + }; + // when + const result = await service.submitProblem(userId, problemId, dto); + // then + expect(result).not.toBeUndefined(); + openSubmission = result; + }); + + it('should submit problem. Closed Submission', async () => { + const userId = user1['id']; + const problemId = problem1['id']; + const dto: SubmitProblemDto = { + code: '', + isPublic: false, + languageId: 0, + language: '', + }; + // when + const result = await service.submitProblem(userId, problemId, dto); + // then + expect(result).not.toBeUndefined(); + closedSubmission = result; + }); + + it('should throw if example not exist', async () => { + //given + const userId = user2['id']; + const problemId = problem2['id']; + const dto: SubmitProblemDto = { + code: '', + isPublic: false, + languageId: 0, + language: '', + }; + //when + try { + await service.submitProblem(userId, problemId, dto); + } catch (err) { + // then + expect(err).toBeInstanceOf(BadRequestException); + } + }); + }); + + describe('listUserSubmissions()', () => { + it('should list user submissions', async () => { + //given + const userId = user1['id']; + const problemId = problem1['id']; + + //when + const submissions = await service.listUserSubmissions( + userId, + problemId, + {} as SubmissionFilterObject, + {} as PaginateObject, + ); + + //then + expect(submissions.data).toBeArray(); + }); + }); + + describe('listPublicSubmission()', () => { + it('should return public submission', async () => { + //given + const problemId = problem1['id']; + //when + const submissions = await service.listPublicSubmission( + problemId, + {} as SubmissionFilterObject, + {} as PaginateObject, + ); + + //then + expect(submissions).toHaveProperty('aggregate'); + expect(submissions).toHaveProperty('data'); + expect(submissions.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: closedSubmission.id, + }), + ]), + ); + }); + }); + + describe('readPublicSubmission()', () => { + it('should return public submission', async () => { + //given + const problemId = problem1['id']; + const openedSubmissionId = openSubmission['id']; + //when + const submission = await service.readPublicSubmission( + problemId, + openedSubmissionId, + ); + //then + expect(submission).not.toBeUndefined(); + }); + + it('should throw if submission is not public', async () => { + //given + const problemId = problem1['id']; + const closedSubmissionId = closedSubmission['id']; + //when + try { + await service.readPublicSubmission(problemId, closedSubmissionId); + } catch (err) { + //then + expect(err).toBeInstanceOf(NotFoundException); + } + }); + }); + + describe('readUserSubmission()', () => { + it('should read user submission', async () => { + //given + const userId = user1['id']; + const problemId = problem1['id']; + const submissionId = openSubmission['id']; + //when + const submission = await service.readUserSubmission( + userId, + problemId, + submissionId, + ); + //then + expect(submission).not.toBeUndefined(); + }); + + it("should throw if submission is not user's", async () => { + //given + const userId = user2['id']; + const problemId = problem1['id']; + const submissionId = openSubmission['id']; + //when + try { + await service.readUserSubmission(userId, problemId, submissionId); + } catch (err) { + //then + expect(err).toBeInstanceOf(NotFoundException); + } + }); + }); + + describe('updateUserSubmission()', () => { + it('should update submission', async () => { + const userId = user1['id']; + const problemId = problem1['id']; + const submissionId = openSubmission['id']; + + const submission = await service.updateUserSubmission( + userId, + problemId, + submissionId, + { + isPublic: true, + }, + ); + + expect(submission).not.toBeUndefined(); + }); + + it('should throw if submission not exist', async () => { + const userId = user2['id']; + const problemId = problem1['id']; + const submissionId = 999999; + + try { + await service.updateUserSubmission(userId, problemId, submissionId, { + isPublic: true, + }); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + + it('should throw if other tries to modify submission', async () => { + const userId = user2['id']; + const problemId = problem1['id']; + const submissionId = openSubmission['id']; + + try { + await service.updateUserSubmission(userId, problemId, submissionId, { + isPublic: true, + }); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('createProblemIssue()', () => { + it('should create problem issue', async () => { + const userId = user1['id']; + const problemId = problem1['id']; + + issue = await service.createProblemIssue( + { + title: 'Issue', + content: 'Issue Content', + }, + userId, + problemId, + ); + + expect(issue).not.toBeUndefined(); + }); + }); + + describe('listProblemIssue()', () => { + it('should list issue', async () => { + const problemId = problem1['id']; + + const issueList = await service.listProblemIssue( + problemId, + {} as PaginateObject, + ); + + expect(issueList).toBeArray(); + }); + }); + + describe('readProblemIssue()', () => { + it('should read problem issue', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + + const readIssue = await service.readProblemIssue(problemId, issueId); + + expect(readIssue).not.toBeUndefined(); + }); + + it('should throw if issue not exist', async () => { + const problemId = problem1['id']; + + try { + await service.readProblemIssue(problemId, 99999999); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('updateProblemIssue()', () => { + it('should update problem issue', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user1['id']; + const dto: CreateProblemIssueDto = { + title: 'Updated Title', + content: 'Updated Content', + }; + + const updatedIssue = await service.updateProblemIssue( + userId, + problemId, + issueId, + dto, + ); + + expect(updatedIssue).not.toBeUndefined(); + }); + + it('should throw if other tries to modify issue', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user2['id']; + const dto: CreateProblemIssueDto = { + title: 'Updated Title', + content: 'Updated Content', + }; + + try { + await service.updateProblemIssue(userId, problemId, issueId, dto); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('createProblemIssueComment()', () => { + it('should create issue comment', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const user1Id = user1['id']; + const dto: CreateProblemIssueCommentDto = { + content: 'content', + }; + + comment = await service.createProblemIssueComment( + user1Id, + problemId, + issueId, + dto, + ); + + expect(comment).not.toBeUndefined(); + }); + + it('should throw if issue not found', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const user1Id = user1['id']; + const dto: CreateProblemIssueCommentDto = { + content: 'content', + }; + + try { + await service.createProblemIssueComment( + user1Id, + problemId, + issueId, + dto, + ); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('updateProblemIssueComment()', () => { + it('should update comment', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const user1Id = user1['id']; + const dto: CreateProblemIssueCommentDto = { + content: 'content', + }; + + comment = await service.updateProblemIssueComment( + user1Id, + problemId, + issueId, + comment['id'], + dto, + ); + + expect(comment).not.toBeUndefined(); + }); + + it("should throw if comment's information is invalid", async () => { + /** + * Invalid information includes + * + * - comment id + * - user id + * - issue id + * - problem id + */ + + const problemId = problem1['id']; + const issueId = 99999; + const user1Id = '99999'; + const dto: CreateProblemIssueCommentDto = { + content: 'content', + }; + + try { + await service.updateProblemIssueComment( + user1Id, + problemId, + issueId, + comment['id'], + dto, + ); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('deleteProblemIssueComment()', () => { + it("should throw if comment is not user's", async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user2['id']; + const commentId = comment['id']; + + try { + await service.deleteProblemIssueComment( + userId, + problemId, + issueId, + commentId, + ); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + + it('should delete comment', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user1['id']; + const commentId = comment['id']; + + await service.deleteProblemIssueComment( + userId, + problemId, + issueId, + commentId, + ); + }); + }); + + describe('deleteProblemIssue()', () => { + it('should throw if other tries to delete', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user2['id']; + + try { + await service.deleteProblemIssue(userId, problemId, issueId); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + + it('should delete issue', async () => { + const problemId = problem1['id']; + const issueId = issue['id']; + const userId = user1['id']; + + const result = await service.deleteProblemIssue( + userId, + problemId, + issueId, + ); + + expect(result).not.toBeUndefined(); + }); }); }); }); diff --git a/src/judge/judge.service.ts b/src/judge/judge.service.ts index 739721e..01e86df 100644 --- a/src/judge/judge.service.ts +++ b/src/judge/judge.service.ts @@ -17,6 +17,7 @@ import { UpdateSubmissionDto, } from './dto'; import { GetLanguagesResponse } from './response/get-languages.response'; +import { ProblemStatus } from 'app/type'; /** * Prisma 2025 -> Target entity not found @@ -46,11 +47,16 @@ export class JudgeService { return list; } - async listProblem(filter: JudgeFilterObject, paginate: PaginateObject) { + async listProblem( + filter: JudgeFilterObject, + paginate: PaginateObject, + req: any, + ) { const problems = await this.prisma.problem.findMany({ ...paginate, where: { ...filter.Where, + isOpen: true, }, orderBy: { ...filter.Orderby, @@ -91,31 +97,124 @@ export class JudgeService { } }); - const correctionRate = (correct / total).toFixed(3); - filteredList.push({ + let correctionRate; + // Correction Rate + if (total) { + correctionRate = (correct / total).toFixed(3); + } else { + correctionRate = 0; + } + + const problemItem = { ...problem, correct, total, correctionRate, - }); + }; + + // Check if correct + if (req?.user) { + const submissionsAggregate = await this.prisma.submission.groupBy({ + by: ['isCorrect'], + _count: { + _all: true, + }, + where: { + userId: req['user']['id'], + problemId: problem.id, + }, + }); + let all = 0; + let success = 0; + let status: ProblemStatus; + for (const group of submissionsAggregate) { + // Count all of submission + all += group._count._all; + // Count correct submission + if (group.isCorrect) { + success += 1; + } + } + // If submission not exist + if (!all) { + status = ProblemStatus.PENDING; + } else if (all && success > 0) { + status = ProblemStatus.SUCCESS; + } else { + status = ProblemStatus.FAIL; + } + problemItem['isSuccess'] = status; + } + + filteredList.push(problemItem); } return filteredList; } - async readProblem(pid: number) { - return await this.prisma.problem.findUniqueOrThrow({ - where: { - id: pid, - }, - include: { - examples: { - where: { - isPublic: true, + async readProblem(pid: number, req: any) { + if (req?.user) { + // If authorized user -> return submission with correct + const problem = await this.prisma.problem.findUnique({ + where: { + id: pid, + }, + include: { + examples: { + where: { + isPublic: true, + }, }, }, - }, - }); + }); + const submissionsAggregate = await this.prisma.submission.groupBy({ + by: ['isCorrect'], + _count: { + _all: true, + }, + where: { + userId: req['user']['id'], + problemId: pid, + }, + }); + let all = 0; + let success = 0; + let status: ProblemStatus; + for (const group of submissionsAggregate) { + // Count all of submission + all += group._count._all; + // Count correct submission + if (group.isCorrect) { + success += 1; + } + } + + // If submission not exist + if (!all) { + status = ProblemStatus.PENDING; + } else if (all && success > 0) { + status = ProblemStatus.SUCCESS; + } else { + status = ProblemStatus.FAIL; + } + return { + ...problem, + isSuccess: status, + }; + } else { + return await this.prisma.problem.findUnique({ + where: { + id: pid, + }, + include: { + examples: { + where: { + isPublic: true, + }, + }, + }, + }); + } } async runProblem(pid: number, dto: RunProblemDto) { @@ -149,7 +248,6 @@ export class JudgeService { ); }), ); - return results.map((result) => { return { isCorrect: result.isCorrect, @@ -333,7 +431,7 @@ export class JudgeService { }, }); return { - aggreate: aggregationMap, + aggregate: aggregationMap, data: submissions, }; } @@ -433,27 +531,34 @@ export class JudgeService { } async readProblemIssue(pid: number, iid: number) { - return await this.prisma.problemIssue.findUniqueOrThrow({ - where: { - id: iid, - problemId: pid, - }, - include: { - issuer: { - select: { - id: true, - nickname: true, - }, + try { + return await this.prisma.problemIssue.findUniqueOrThrow({ + where: { + id: iid, + problemId: pid, }, - problem: { - select: { - id: true, - title: true, + include: { + issuer: { + select: { + id: true, + nickname: true, + }, }, + problem: { + select: { + id: true, + title: true, + }, + }, + comments: true, }, - comments: true, - }, - }); + }); + } catch (err) { + if (err.code === 'P2025') { + throw new ForbiddenException('ISSUE_NOT_FOUND'); + } + throw err; + } } async createProblemIssue( @@ -528,21 +633,23 @@ export class JudgeService { id: iid, }, }); + // Create new issue comment + return await this.prisma.problemIssueComment.create({ + data: { + userId: uid, + issueId: iid, + problemId: pid, + content: dto.content, + }, + }); } catch (err) { - if (err.code === 'P2025') { + // P2003 + // Unique Constraint Error + if (err.code === 'P2025' || err.code === 'P2003') { throw new ForbiddenException('ISSUE_NOT_FOUND'); } throw err; } - // Create new issue comment - return await this.prisma.problemIssueComment.create({ - data: { - userId: uid, - issueId: iid, - problemId: pid, - content: dto.content, - }, - }); } async updateProblemIssueComment( diff --git a/src/judge/response/list-problem.response.ts b/src/judge/response/list-problem.response.ts index 7c87b93..a1b1cb0 100644 --- a/src/judge/response/list-problem.response.ts +++ b/src/judge/response/list-problem.response.ts @@ -1,14 +1,15 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; +import { ProblemStatus } from 'app/type'; import { ProblemDomain, UserDomain } from 'domains'; export class ListProblemContributerResponse extends PickType(UserDomain, [ 'nickname', ]) {} -export class ListProblemResponse extends PickType(ProblemDomain, [ - 'id', - 'title', -]) { +export class ListProblemUnAuthenticatedResponse extends PickType( + ProblemDomain, + ['id', 'title'], +) { @ApiProperty({ type: ListProblemContributerResponse, }) @@ -23,3 +24,10 @@ export class ListProblemResponse extends PickType(ProblemDomain, [ @ApiProperty() correctionRate: number; } + +export class ListProblemAuthenticatedResponse extends ListProblemUnAuthenticatedResponse { + @ApiProperty({ + enum: ProblemStatus, + }) + isSuccess: ProblemStatus; +} diff --git a/src/judge/response/read-problem.response.ts b/src/judge/response/read-problem.response.ts index 2a46f83..b8dfe11 100644 --- a/src/judge/response/read-problem.response.ts +++ b/src/judge/response/read-problem.response.ts @@ -1,12 +1,20 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ProblemStatus } from 'app/type'; import { ProblemDomain, ProblemExampleDomain } from 'domains'; class ReadProblemResponseExamples extends ProblemExampleDomain {} -export class ReadProblemResponse extends ProblemDomain { +export class ReadProblemUnauthenticatedResponse extends ProblemDomain { @ApiProperty({ type: ReadProblemResponseExamples, isArray: true, }) - examples: ReadProblemResponse[]; + examples: ReadProblemResponseExamples[]; +} + +export class ReadProblemAuthenticatedResponse extends ReadProblemUnauthenticatedResponse { + @ApiProperty({ + enum: ProblemStatus, + }) + isSuccess: ProblemStatus; } diff --git a/src/main.ts b/src/main.ts index 8542e24..dbed08d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,17 @@ import { ValidationPipe } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { InitializeAdmin } from './admin-init'; import { AppModule } from './app.module'; +import { SentryFilter } from './filter'; +import * as Sentry from '@sentry/node'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + // Get Express http adapter + const { httpAdapter } = app.get(HttpAdapterHost); + app.enableCors(); app.useGlobalPipes( // Docs: https://docs.nestjs.com/techniques/validation @@ -18,6 +24,11 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle('Online-Judge-Server') .setDescription('Online Judge Server') + .setContact( + 'J-Hoplin', + 'https://github.com/J-Hoplin', + 'hoplin.dev@gmail.com', + ) .setVersion('1.0') .addBearerAuth() .build(); @@ -30,6 +41,14 @@ async function bootstrap() { explorer: true, }); - await app.listen(3000); + // Sentry + Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, + }); + app.useGlobalFilters(new SentryFilter(httpAdapter)); + + await app.listen(process.env.PORT || 3000); } bootstrap(); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index dd00e1c..c58c76a 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -79,13 +79,13 @@ export class PrismaService } async deleteAll() { - // Consider order of delete by relation - await this.$transaction([ - this.problemExample.deleteMany(), - this.problem.deleteMany(), - this.user.deleteMany(), - this.problemIssue.deleteMany(), - this.problemIssueComment.deleteMany(), - ]); + // await this.$transaction([ + // this.submission.deleteMany(), + // this.problemIssueComment.deleteMany(), + // this.problemIssue.deleteMany(), + // this.problemExample.deleteMany(), + // this.problem.deleteMany(), + // this.user.deleteMany(), + // ]); } } diff --git a/src/type.ts b/src/type.ts index b8cbec1..d6645c8 100644 --- a/src/type.ts +++ b/src/type.ts @@ -3,6 +3,12 @@ export type JwtPayload = { email: string; }; +export enum ProblemStatus { + PENDING = 'PENDING', + SUCCESS = 'SUCCESS', + FAIL = 'FAIL', +} + // Common Prisma Enum type // Convert enum to interface(type) diff --git a/src/user/dto/update-user-info.dto.ts b/src/user/dto/update-user-info.dto.ts index 6c9f61d..d591de5 100644 --- a/src/user/dto/update-user-info.dto.ts +++ b/src/user/dto/update-user-info.dto.ts @@ -31,4 +31,12 @@ export class UpdateUserInfoDto extends PickType(UserDomain, [ @IsString() @IsNotEmpty() password: string; + + @ApiProperty({ + required: false, + type: 'string', + format: 'binary', + }) + @IsOptional() + profile?: Express.Multer.File; } diff --git a/src/user/response/get-profile.response.ts b/src/user/response/get-profile.response.ts new file mode 100644 index 0000000..0824337 --- /dev/null +++ b/src/user/response/get-profile.response.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { UserDomain } from 'domains'; + +export class GetProfileResponse extends OmitType(UserDomain, ['password']) {} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 078da7a..df498a5 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -6,33 +6,40 @@ import { Param, Patch, Post, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { LocalGuard } from 'app/auth/guard'; import { CheckCredentialDto, UpdatePasswordDto } from './dto'; import { UpdateUserInfoDto } from './dto/update-user-info.dto'; import { UserDocs } from './user.docs'; import { UserService } from './user.service'; -import { GetUser } from 'app/decorator'; +import { AllowPublic, GetUser } from 'app/decorator'; import { Role } from 'app/decorator/role.decorator'; import { RoleGuard } from 'app/guard'; import { SetContributerDto } from './dto/set-contributor'; import { UserDomain } from 'domains'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + FileNameTransformPipe, + FileOptionFactory, + UserProfileImageArtifactConfig, +} from 'app/config'; @Controller('user') +@UseGuards(LocalGuard) @UserDocs.Controller() export class UserController { constructor(private userService: UserService) {} @Get('profile') - @UseGuards(LocalGuard) @UserDocs.GetMyProfile() getMyProfile(@GetUser() user: UserDomain) { - return user; + return this.userService.getMyProfile(user); } @Get('profile/:uid') - @UseGuards(LocalGuard) @UserDocs.GetProfile() getProfile(@Param('uid') uid: string) { return this.userService.getProfile(uid); @@ -40,20 +47,29 @@ export class UserController { @Post('credential') @HttpCode(200) + @AllowPublic() @UserDocs.CheckCredential() checkCredential(@Body() dto: CheckCredentialDto) { return this.userService.checkCredential(dto); } @Patch('profile') - @UseGuards(LocalGuard) + @UseInterceptors( + FileInterceptor( + 'profile', + FileOptionFactory(UserProfileImageArtifactConfig), + ), + ) @UserDocs.updateUserInfo() - updateUserInfo(@GetUser() user: UserDomain, @Body() dto: UpdateUserInfoDto) { - return this.userService.updateUserInfo(user, dto); + updateUserInfo( + @GetUser() user: UserDomain, + @Body() dto: UpdateUserInfoDto, + @UploadedFile(FileNameTransformPipe) file: Express.Multer.File, + ) { + return this.userService.updateUserInfo(user, dto, file); } @Patch('password') - @UseGuards(LocalGuard) @UserDocs.updatePassword() updatePassword(@GetUser() user: UserDomain, @Body() dto: UpdatePasswordDto) { return this.userService.updatePassword(user, dto); @@ -62,7 +78,6 @@ export class UserController { @Patch(['admin/role', 'role']) @Role(['Admin']) @UseGuards(RoleGuard) - @UseGuards(LocalGuard) @UserDocs.setRole() setRole(@GetUser() user: UserDomain, @Body() dto: SetContributerDto) { return this.userService.setRole(user, dto); diff --git a/src/user/user.docs.ts b/src/user/user.docs.ts index 779b50a..8db96d0 100644 --- a/src/user/user.docs.ts +++ b/src/user/user.docs.ts @@ -2,12 +2,13 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, + ApiConsumes, ApiOkResponse, ApiOperation, ApiTags, } from '@nestjs/swagger'; import { CheckCredentialResponse } from './response/check-credential.response'; -import { UserDomain } from 'domains'; +import { GetProfileResponse } from './response/get-profile.response'; export class UserDocs { public static Controller() { @@ -20,7 +21,7 @@ export class UserDocs { summary: '프로필 조회', }), ApiOkResponse({ - type: UserDomain, + type: GetProfileResponse, }), ApiBearerAuth(), ); @@ -32,7 +33,7 @@ export class UserDocs { summary: '다른 사용자 프로필 조회', }), ApiOkResponse({ - type: UserDomain, + type: GetProfileResponse, }), ApiBadRequestResponse({ description: ['USER_NOT_FOUND'].join(', '), @@ -60,8 +61,9 @@ export class UserDocs { summary: '사용자 정보 업데이트', description: 'Password가 요구됩니다.', }), + ApiConsumes('multipart/form-data'), ApiOkResponse({ - type: UserDomain, + type: GetProfileResponse, }), ApiBearerAuth(), ); diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 1ddbb61..122d53c 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserService } from './user.service'; +import { AwsS3Module } from 's3/aws-s3'; @Module({ + imports: [AwsS3Module], controllers: [UserController], providers: [UserService], + exports: [UserService], }) export class UserModule {} diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index e158580..29dedad 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -7,6 +7,9 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { CredentialType } from './dto'; import * as bcrypt from 'bcryptjs'; import { UserDomain } from 'domains'; +import { AwsS3Module, AwsS3Service } from 's3/aws-s3'; +import { AwsS3LibraryMockProvider } from 'test/mock.provider'; +import { v4 } from 'uuid'; describe('UserService', () => { let service: UserService; @@ -24,9 +27,12 @@ describe('UserService', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [PrismaModule], + imports: [PrismaModule, AwsS3Module], providers: [UserService], - }).compile(); + }) + .overrideProvider(AwsS3Service) + .useValue(AwsS3LibraryMockProvider.useValue) + .compile(); service = module.get(UserService); prisma = module.get(PrismaService); @@ -62,6 +68,8 @@ describe('UserService', () => { it("should get user's profile", async () => { const result = await service.getProfile(user1.id); expect(result.id).not.toBeUndefined(); + expect(result).toBeObject(); + expect(result).toContainKeys(Object.keys(UserDomain)); }); it('should throw if user not found', async () => { try { @@ -108,25 +116,39 @@ describe('UserService', () => { describe('updateUserInfo()', () => { it('should update user profile', async () => { - const result = await service.updateUserInfo(user3, { - blog: 'jhoplin7259.tistory.com', - nickname: 'nickname', - github: 'J-hoplin1', - message: 'message', - password: user3Singup.password, - }); + const result = await service.updateUserInfo( + { + ...user3, + }, + { + blog: 'jhoplin7259.tistory.com', + nickname: v4().split('-')[0], + github: 'J-hoplin1', + message: 'message', + password: user3Singup.password, + }, + null, + ); expect(result).not.toBeUndefined(); + expect(result).toBeObject(); + expect(result).toContainAllKeys(Object.keys(user1)); }); it('should throw if password unmatched', async () => { try { - await service.updateUserInfo(user3, { - blog: 'jhoplin7259.tistory.com', - nickname: 'nickname2', - github: 'J-hoplin1', - message: 'message', - password: 'wrong-password', - }); + await service.updateUserInfo( + { + ...user3, + }, + { + blog: 'jhoplin7259.tistory.com', + nickname: 'nickname2', + github: 'J-hoplin1', + message: 'message', + password: 'wrong-password', + }, + null, + ); } catch (err) { expect(err).toBeInstanceOf(UnauthorizedException); } @@ -161,20 +183,30 @@ describe('UserService', () => { describe('setRole()', () => { it("should update user's role", async () => { - const updated = await service.setRole(user2, { - role: 'Contributer', - targetId: user3.id, - }); + const updated = await service.setRole( + { + ...user2, + }, + { + role: 'Contributer', + targetId: user3.id, + }, + ); expect(updated.id).toBe(user3.id); expect(updated.type).toBe('Contributer'); }); it('should throw if user not found', async () => { try { - await service.setRole(user2, { - role: 'Contributer', - targetId: 'target', - }); + await service.setRole( + { + ...user2, + }, + { + role: 'Contributer', + targetId: 'target', + }, + ); } catch (err) { expect(err).toBeInstanceOf(BadRequestException); } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 05e32bd..601fd50 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -11,10 +11,22 @@ import { UserDomain } from 'domains'; import { CheckCredentialDto, CredentialType, UpdatePasswordDto } from './dto'; import { SetContributerDto } from './dto/set-contributor'; import { UpdateUserInfoDto } from './dto/update-user-info.dto'; +import { AwsS3Service } from 's3/aws-s3'; +import { UserProfileImageDir } from 'app/config'; @Injectable() export class UserService { - constructor(private prisma: PrismaService) {} + constructor(private prisma: PrismaService, private s3: AwsS3Service) {} + + async getMyProfile(user: UserDomain) { + user.profileImage = await this.s3.getSignedURL( + user.profileImage, + UserProfileImageDir, + ); + + delete user.password; + return user; + } async getProfile(uid: string) { const user = await this.prisma.user.findUnique({ @@ -22,9 +34,19 @@ export class UserService { id: uid, }, }); + if (!user) { throw new BadRequestException('USER_NOT_FOUND'); } + + user.profileImage = await this.s3.getSignedURL( + user.profileImage, + UserProfileImageDir, + ); + + // Remove Password field of user + delete user.password; + return user; } @@ -52,14 +74,29 @@ export class UserService { }; } - async updateUserInfo(user: UserDomain, dto: UpdateUserInfoDto) { + async updateUserInfo( + user: UserDomain, + dto: UpdateUserInfoDto, + file?: Express.Multer.File, + ) { const validatePassword = await bcrypt.compare(dto.password, user.password); // If fail to validate if (!validatePassword) { throw new UnauthorizedException('WRONG_CREDENTIAL'); } + delete dto.password; + delete dto.profile; + + const data = { + ...dto, + }; + + if (file) { + const key = await this.s3.uploadFile(file, UserProfileImageDir); + data['profileImage'] = key; + } try { // If not update @@ -68,9 +105,16 @@ export class UserService { id: user.id, }, data: { - ...dto, + ...data, }, }); + updatedUser.profileImage = await this.s3.getSignedURL( + updatedUser.profileImage, + UserProfileImageDir, + ); + + delete user.password; + return updatedUser; } catch (err) { if (err instanceof PrismaClientKnownRequestError) { diff --git a/src/worker/worker.controller.ts b/src/worker/worker.controller.ts new file mode 100644 index 0000000..fbc6db0 --- /dev/null +++ b/src/worker/worker.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { WorkerDto } from 'aws-sqs/aws-sqs/dto'; +import { WorkerService } from './worker.service'; +import { WorkerDocs } from './worker.docs'; + +@Controller('worker') +@WorkerDocs.Controller() +export class WorkerController { + constructor(private workerService: WorkerService) {} + + @HttpCode(200) + @Post() + @WorkerDocs.WorkerController() + workerController(@Body() dto: WorkerDto) { + return this.workerService.worker(dto); + } +} diff --git a/src/worker/worker.docs.ts b/src/worker/worker.docs.ts new file mode 100644 index 0000000..a6dac40 --- /dev/null +++ b/src/worker/worker.docs.ts @@ -0,0 +1,18 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +export class WorkerDocs { + public static Controller() { + return applyDecorators(ApiTags('Async Task')); + } + public static WorkerController() { + return applyDecorators( + ApiOperation({ + summary: 'AWS SQS worker Controller', + }), + ApiOkResponse({ + description: 'If worker task success return 200', + }), + ); + } +} diff --git a/src/worker/worker.module.ts b/src/worker/worker.module.ts new file mode 100644 index 0000000..95140f0 --- /dev/null +++ b/src/worker/worker.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { WorkerService } from './worker.service'; +import { WorkerController } from './worker.controller'; +import { Judge0Module } from 'judge/judge0'; + +@Module({ + imports: [Judge0Module], + providers: [WorkerService], + controllers: [WorkerController], +}) +export class WorkerModule {} diff --git a/src/worker/worker.service.ts b/src/worker/worker.service.ts new file mode 100644 index 0000000..1021d4e --- /dev/null +++ b/src/worker/worker.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from 'app/prisma/prisma.service'; +import { WorkerDto } from 'aws-sqs/aws-sqs/dto'; +import { ProblemDomain, SubmissionDomain } from 'domains'; +import { Judge0Service } from 'judge/judge0'; + +@Injectable() +export class WorkerService { + private log: Logger = new Logger(); + + constructor(private prisma: PrismaService, private judge0: Judge0Service) {} + + async worker(dto: WorkerDto) { + switch (dto.message) { + // Re-Correct if contributer modify example + case 'RE_CORRECTION': + return await this.reCorrectSubmissions(dto.id as number); + break; + // Code Submit + case 'CODE_SUBMIT': + break; + default: + // If message with unsupported message type -> Consume and ignore + this.log.warn(`Unknown queue item detected: ${dto}`); + return true; + } + } + + async reCorrectSubmissions(id: number) { + try { + // Get all of the submission with problem id + const submissions = await this.prisma.submission.findMany({ + where: { + problemId: id, + }, + }); + + let problem: ProblemDomain; + try { + problem = await this.prisma.problem.findUnique({ + where: { + id: id, + }, + }); + } catch (err) { + // If target problem is unappropriate problem for recorrection + return true; + } + for (const submission of submissions) { + await this.correctionWithExample(id, submission, problem); + } + return true; + } catch (err) { + // If fail -> Do logging and throw error again + console.error(`Fail to recorrect submission: ${id}`); + console.error(`Message: ${err}`); + throw err; + } + } + + async correctionWithExample( + problemId: number, + submission: SubmissionDomain, + problem: ProblemDomain, + ) { + const examples = await this.prisma.problemExample.findMany({ + where: { + problemId: problemId, + }, + }); + + const results = await Promise.all( + examples.map((example) => { + return this.judge0.submit( + submission.languageId, + submission.code, + example.output, + example.input, + problem.timeLimit, + problem.memoryLimit, + ); + }), + ); + results.sort((x, y) => { + // Firstly sort by time + if (x.time > y.time) { + return 1; + } + if (x.time < y.time) { + return -1; + } + + // If time is same, sort as memory + if (x.memory > y.memory) { + return 1; + } + if (x.memory < y.memory) { + return -1; + } + }); + + // Filter wrong answer + const checkWrongAnswer = results.filter((result) => !result.isCorrect); + checkWrongAnswer.sort((x, y) => { + // Firstly sort by time + if (x.time > y.time) { + return 1; + } + if (x.time < y.time) { + return -1; + } + + // If time is same, sort as memory + if (x.memory > y.memory) { + return 1; + } + if (x.memory < y.memory) { + return -1; + } + }); + + const data = checkWrongAnswer.length ? checkWrongAnswer[0] : results[0]; + + await this.prisma.$transaction([ + this.prisma.submission.update({ + where: { + id: submission.id, + }, + data: { + memory: data.memory, + time: data.time, + isCorrect: data.isCorrect, + response: data.description, + }, + }), + ]); + return true; + } +} diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 49d07be..262f3bf 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -6,8 +6,18 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "testTimeout": 20000, + "setupFilesAfterEnv": ["/../test/jest-extended.ts"], "coverageDirectory": "../e2e-coverage", - "collectCoverageFrom": ["**/*\\.controller\\.(t|j)s", "**/*\\.dto\\.(t|j)s"], + "collectCoverageFrom": [ + "**/*\\.controller\\.(t|j)s", + "**/*\\.dto\\.(t|j)s", + "**/*\\.service\\.(t|j)s", + "!**/worker/**", + "!**/system-logger/**", + "!**/prisma/**", + "!**/artifact/**" + ], "moduleNameMapper": { "app/(.*)": "/../src/$1", "test/(.*)": "/../test/$1", @@ -15,6 +25,8 @@ "domains/(.*)": "/../domain/$1", "s3/aws-s3": "/../libs/aws-s3/src", "s3/aws-s3/(.*)": "/../libs/aws-s3/src/$1", + "aws-sqs/aws-sqs": "/../libs/aws-sqs/src", + "aws-sqs/aws-sqs/(.*)": "/../libs/aws-sqs/src/$1", "judge/judge0": "/../libs/judge0/src", "judge/judge0/(.*)": "/../libs/judge0/src/$1" } diff --git a/test/jest-extended.ts b/test/jest-extended.ts new file mode 100644 index 0000000..9ccbeec --- /dev/null +++ b/test/jest-extended.ts @@ -0,0 +1,3 @@ +// extend jest matcher +import * as matchers from 'jest-extended'; +expect.extend(matchers); diff --git a/test/jest-unit.json b/test/jest-unit.json index 5e769e1..9d626df 100644 --- a/test/jest-unit.json +++ b/test/jest-unit.json @@ -6,8 +6,16 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "testTimeout": 20000, + "setupFilesAfterEnv": ["/../test/jest-extended.ts"], "coverageDirectory": "../unit-coverage", - "collectCoverageFrom": ["**/*\\.service\\.(t|j)s"], + "collectCoverageFrom": [ + "**/*\\.service\\.(t|j)s", + "!**/worker/**", + "!**/system-logger/**", + "!**/prisma/**", + "!**/artifact/**" + ], "moduleNameMapper": { "app/(.*)": "/../src/$1", "test/(.*)": "/../test/$1", @@ -16,6 +24,8 @@ "s3/aws-s3": "/../libs/aws-s3/src", "s3/aws-s3/(.*)": "/../libs/aws-s3/src/$1", "judge/judge0": "/../libs/judge0/src", - "judge/judge0/(.*)": "/../libs/judge0/src/$1" + "judge/judge0/(.*)": "/../libs/judge0/src/$1", + "aws-sqs/aws-sqs": "/../libs/aws-sqs/src", + "aws-sqs/aws-sqs/(.*)": "/../libs/aws-sqs/src/$1" } } diff --git a/test/mock.provider.ts b/test/mock.provider.ts new file mode 100644 index 0000000..a2485b2 --- /dev/null +++ b/test/mock.provider.ts @@ -0,0 +1,62 @@ +import { ValueProvider } from '@nestjs/common'; +import { AwsSqsService } from 'aws-sqs/aws-sqs'; +import { Judge0Service } from 'judge/judge0'; +import { AwsS3Service } from 's3/aws-s3'; + +/** + * Mock Object of Judge0 Service + */ +export const JudgeLibraryMockProvider: ValueProvider = { + provide: Judge0Service, + useValue: { + getLanguages: () => [ + { + id: 0, + name: 'string', + }, + { + id: 0, + name: 'string', + }, + ], + submit: (...args) => { + return { + isCorrect: true, + description: 'CORRECT', + languageId: 71, + memory: 3260, + time: 0.015, + statusId: 3, + output: { + expect: 'hello', + input: '', + stdout: 'hello\n', + message: null, + }, + }; + }, + }, +}; + +/** + * Mock Object of AWS S3 + */ +export const AwsS3LibraryMockProvider: ValueProvider = { + provide: AwsS3Service, + useValue: { + uploadFile: jest.fn((...args) => 'some-url'), + getStaticURL: jest.fn((...args) => 'some-url'), + getSignedURL: jest.fn((...args) => 'some-url'), + removeObject: jest.fn((...args) => 'some-url'), + }, +}; + +/** + * Mock Object of AWS SQS + */ +export const AwsSQSLibraryMockProvider: ValueProvider = { + provide: AwsSqsService, + useValue: { + sendTask: jest.fn((...args) => true), + }, +}; diff --git a/tsconfig.json b/tsconfig.json index 70f21f9..73bce71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,36 +18,16 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, "paths": { - "app": [ - "src" - ], - "app/*": [ - "src/*" - ], - "domains": [ - "domain" - ], - "domains/*": [ - "domain/*" - ], - "s3/aws-s3": [ - "libs/aws-s3/src" - ], - "s3/aws-s3/*": [ - "libs/aws-s3/src/*" - ], - "judge/judge0": [ - "libs/judge0/src" - ], - "judge/judge0/*": [ - "libs/judge0/src/*" - ], - "aws-sqs/aws-sqs": [ - "libs/aws-sqs/src" - ], - "aws-sqs/aws-sqs/*": [ - "libs/aws-sqs/src/*" - ] + "app": ["src"], + "app/*": ["src/*"], + "domains": ["domain"], + "domains/*": ["domain/*"], + "s3/aws-s3": ["libs/aws-s3/src"], + "s3/aws-s3/*": ["libs/aws-s3/src/*"], + "judge/judge0": ["libs/judge0/src"], + "judge/judge0/*": ["libs/judge0/src/*"], + "aws-sqs/aws-sqs": ["libs/aws-sqs/src"], + "aws-sqs/aws-sqs/*": ["libs/aws-sqs/src/*"] } } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 23e5345..c7c6195 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1817,6 +1817,54 @@ resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.6.0.tgz#82c445aa10633bbc0388aa2d6e411a0bd94c9439" integrity sha512-Mt2q+GNJpU2vFn6kif24oRSBQv1KOkYaterQsi0k2/lA+dLvhRX6Lm26gon6PYHwUM8/h8KRgXIUMU0PCLB6bw== +"@sentry-internal/tracing@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.93.0.tgz#8cee8b610695d828af75edd2929b64b7caf0385d" + integrity sha512-DjuhmQNywPp+8fxC9dvhGrqgsUb6wI/HQp25lS2Re7VxL1swCasvpkg8EOYP4iBniVQ86QK0uITkOIRc5tdY1w== + dependencies: + "@sentry/core" "7.93.0" + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + +"@sentry/core@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.93.0.tgz#50a14bf305130dfef51810e4c97fcba4972a57ef" + integrity sha512-vZQSUiDn73n+yu2fEcH+Wpm4GbRmtxmnXnYCPgM6IjnXqkVm3awWAkzrheADblx3kmxrRiOlTXYHw9NTWs56fg== + dependencies: + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + +"@sentry/node@^7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.93.0.tgz#7786d05d1e3e984207a866b07df1bf891355892e" + integrity sha512-nUXPCZQm5Y9Ipv7iWXLNp5dbuyi1VvbJ3RtlwD7utgsNkRYB4ixtKE9w2QU8DZZAjaEF6w2X94OkYH6C932FWw== + dependencies: + "@sentry-internal/tracing" "7.93.0" + "@sentry/core" "7.93.0" + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + https-proxy-agent "^5.0.0" + +"@sentry/profiling-node@^1.3.5": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@sentry/profiling-node/-/profiling-node-1.3.5.tgz#e7e15fae88745b4c062ca49086bf85688b6ffe66" + integrity sha512-n2bfEbtLW3WuIMQGyxKJKzBNZOb1JYfMeJQ2WQn/42F++69m+u7T0S3EDGRN0Y//fbt5+r0any+4r3kChRXZkQ== + dependencies: + detect-libc "^2.0.2" + node-abi "^3.52.0" + +"@sentry/types@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.93.0.tgz#d76d26259b40cd0688e1d634462fbff31476c1ec" + integrity sha512-UnzUccNakhFRA/esWBWP+0v7cjNg+RilFBQC03Mv9OEMaZaS29zSbcOGtRzuFOXXLBdbr44BWADqpz3VW0XaNw== + +"@sentry/utils@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.93.0.tgz#36225038661fe977baf01e4695ef84794d591e45" + integrity sha512-Iovj7tUnbgSkh/WrAaMrd5UuYjW7AzyzZlFDIUrwidsyIdUficjCG2OIxYzh76H6nYIx9SxewW0R54Q6XoB4uA== + dependencies: + "@sentry/types" "7.93.0" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -3121,6 +3169,13 @@ acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.2.tgz#ca0d78b51895be5390a5903c5b3bdcdaf78ae40b" integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv-formats@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -3785,7 +3840,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -3838,6 +3893,11 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-libc@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" + integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -4601,6 +4661,14 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -4937,7 +5005,7 @@ jest-config@^29.7.0: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: +jest-diff@^29.0.0, jest-diff@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== @@ -4977,7 +5045,15 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" -jest-get-type@^29.6.3: +jest-extended@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/jest-extended/-/jest-extended-4.0.2.tgz#d23b52e687cedf66694e6b2d77f65e211e99e021" + integrity sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog== + dependencies: + jest-diff "^29.0.0" + jest-get-type "^29.0.0" + +jest-get-type@^29.0.0, jest-get-type@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== @@ -5664,6 +5740,13 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +node-abi@^3.52.0: + version "3.54.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.54.0.tgz#f6386f7548817acac6434c6cba02999c9aebcc69" + integrity sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA== + dependencies: + semver "^7.3.5" + node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548"