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 @@
-
-
-
+# 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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+## 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"