diff --git a/.github/workflows/be-cd.yml b/.github/workflows/be-cd.yml index e833bb91..6e5c2970 100644 --- a/.github/workflows/be-cd.yml +++ b/.github/workflows/be-cd.yml @@ -1,9 +1,9 @@ -name: froxy Continuous Delivery +name: froxy BE Continuous Delivery on: push: branches: - - release # release 브랜치에 푸시될 때 실행 + - deploy jobs: be-cd: @@ -51,7 +51,7 @@ jobs: docker rm froxy-container fi docker pull ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest && \ - docker run --network froxy-network -d --name froxy-container -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest + docker run --network host -d --name froxy-container -v /var/run/docker.sock:/var/run/docker.sock ${{ secrets.DOCKER_USERNAME }}/froxy-server:latest docker image prune -f " diff --git a/.github/workflows/be-ci.yml b/.github/workflows/be-ci.yml index bd6d7bbf..297c12cf 100644 --- a/.github/workflows/be-ci.yml +++ b/.github/workflows/be-ci.yml @@ -1,4 +1,4 @@ -name: Froxy Continuous Integration +name: Froxy-BE Continuous Integration on: push: diff --git a/.github/workflows/fe-cd.yml b/.github/workflows/fe-cd.yml index 12b8fcae..75f86ca6 100644 --- a/.github/workflows/fe-cd.yml +++ b/.github/workflows/fe-cd.yml @@ -1,10 +1,9 @@ -name: Froxy Continuous Integration +name: Froxy-FE Continuous Integration on: push: branches: - - release - + - deploy jobs: fe-cd: runs-on: ubuntu-20.04 diff --git a/.github/workflows/fe-ci.yml b/.github/workflows/fe-ci.yml index 6fbc6ea6..46c88dbb 100644 --- a/.github/workflows/fe-ci.yml +++ b/.github/workflows/fe-ci.yml @@ -1,4 +1,4 @@ -name: Froxy Continuous Integration +name: Froxy-CI Continuous Integration on: push: diff --git a/README.md b/README.md index d8af2365..d76dec85 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,310 @@ -# web38-Froxy -

-
-

+
-> 빠르고 간편하게 코드를 실행하세요 🐸 +

Froxy

-Froxy는 개구리를 뜻하는 ‘Frog’와 ‘Proxy’의 합성어로, 사용자가 직접 코드를 실행하지 않고도 결과를 빠르게 확인할 수 있는 서비스입니다. 🐸💻 +Portfolio UI Kit Cover -Gist에서 코드를 복제하고 실행 환경을 설정하는 번거로움 없이, Froxy와 함께라면 폴짝! 뛰어넘어 간편하게 코드를 실행하고 결과를 확인할 수 있습니다. 다양한 기능을 통해 코드를 테스트하고 실행 결과를 즉시 확인해 보세요! +
빠르고 간편하게 코드를 실행하세요 🐸
-

+
+ +> Froxy는 개구리를 뜻하는 ‘Frog’와 ‘Proxy’의 합성어로, 사용자가 직접 코드를 실행하지 않고도 결과를 빠르게 확인할 수 있는 서비스입니다. 🐸💻
+> +> Gist에서 코드를 복제하고 실행 환경을 설정하는 번거로움 없이, Froxy와 함께라면 폴짝! 뛰어넘어 간편하게 코드를 실행하고 결과를 확인할 수 있습니다. 다양한 기능을 통해 코드를 테스트하고 실행 결과를 즉시 확인해 보세요! + +
+ +
+ +

+지금 코드 실행하러 가기 +

+ +[팀 노션](https://freckle-calliandra-79a.notion.site/Team38-F-Rog-12d9038c617380509fbdf4eb928e4238) +| +[팀 피그마](https://camo.githubusercontent.com/8a61ef97622df78c36d2ac0c400be9d154e0a756137e6752117de9bc1a78660a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4669676d612d4632344531453f7374796c653d666f722d7468652d6261646765266c6f676f3d6669676d61266c6f676f436f6c6f723d7768697465) +| +[팀 피그잼](https://www.figma.com/board/NYv2EBl18ZcY9sxcYuqn9M/%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B3%B4%EB%93%9C!?node-id=0-1&node-type=canvas&t=USILPXIX2R8atRyd-0) +| +[개발 위키](https://freckle-calliandra-79a.notion.site/12d9038c6173807297b4d21e68b642c8) + +
+ +
+

목차

+ +- [주제 선정 동기](https://github.com/boostcampwm-2024/web38-Froxy#%EF%B8%8F-%EC%A3%BC%EC%A0%9C-%EC%84%A0%EC%A0%95-%EB%8F%99%EA%B8%B0) +- [주요 기능](https://github.com/boostcampwm-2024/web38-Froxy#%EC%A3%BC%EC%9A%94-%EA%B8%B0%EB%8A%A5) + - [Gist 코드를 빠르게 게시하기](https://github.com/boostcampwm-2024/web38-Froxy#gist-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B2%8C%EC%8B%9C%ED%95%98%EA%B8%B0) + - [다른 사람의 Gist 확인하기](https://github.com/boostcampwm-2024/web38-Froxy#%EB%8B%A4%EB%A5%B8-%EC%82%AC%EB%9E%8C%EC%9D%98-gist-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0) + - [Gist 코드를 빠르게 실행하기](https://github.com/boostcampwm-2024/web38-Froxy#gist-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0) +- [FE 기술적 도전](https://github.com/boostcampwm-2024/web38-Froxy#fe-%EA%B8%B0%EC%88%A0%EC%A0%81-%EB%8F%84%EC%A0%84) + + - [QueryKey Factory로 QueryKey 구조화 하기](https://github.com/boostcampwm-2024/web38-Froxy#querykey-factory%EB%A1%9C-querykey-%EA%B5%AC%EC%A1%B0%ED%99%94-%ED%95%98%EA%B8%B0) + - [계층화와 도메인 모델](https://github.com/boostcampwm-2024/web38-Froxy#%EA%B3%84%EC%B8%B5%ED%99%94%EC%99%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8) + - [MSW로 개발 효율화하기](https://github.com/boostcampwm-2024/web38-Froxy#msw%EB%A1%9C-%EA%B0%9C%EB%B0%9C-%ED%9A%A8%EC%9C%A8%ED%99%94%ED%95%98%EA%B8%B0) + - [Suspense, ErrorBoundary로 Fallback 구현하기](https://github.com/boostcampwm-2024/web38-Froxy#suspense-errorboundary%EB%A1%9C-fallback-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0) + +- [BE 기술적 도전](https://github.com/boostcampwm-2024/web38-Froxy#be-%EA%B8%B0%EC%88%A0%EC%A0%81-%EB%8F%84%EC%A0%84) + + - [docker를 이용한 코드 실행](https://github.com/boostcampwm-2024/web38-Froxy#docker%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BD%94%EB%93%9C-%EC%8B%A4%ED%96%89) + - [queue & pool을 이용한 스케줄링](https://github.com/boostcampwm-2024/web38-Froxy#queue--pool%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%A7%81) + - [Octokit 클라이언트 대신 직접 Gist API 모듈화](https://github.com/boostcampwm-2024/web38-Froxy#octokit-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EB%8C%80%EC%8B%A0-%EC%A7%81%EC%A0%91-gist-api-%EB%AA%A8%EB%93%88%ED%99%94) + - [TypeORM을 통한 다대다 테이블 관리](https://github.com/boostcampwm-2024/web38-Froxy#typeorm%EC%9D%84-%ED%86%B5%ED%95%9C-%EB%8B%A4%EB%8C%80%EB%8B%A4-%ED%85%8C%EC%9D%B4%EB%B8%94-%EA%B4%80%EB%A6%AC) + +- [기술 스택](https://github.com/boostcampwm-2024/web38-Froxy#%EA%B8%B0%EC%88%A0-%EC%8A%A4%ED%83%9D) +- [프로젝트 아키텍처](https://github.com/boostcampwm-2024/web38-Froxy#-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98) +- [프로젝트 Flow](https://github.com/boostcampwm-2024/web38-Froxy#%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-flow) +- [팀원](https://github.com/boostcampwm-2024/web38-Froxy#-%ED%8C%80%EC%9B%90) + +
## ⭐️ 주제 선정 동기 -- 베이직, 챌린지에서 미션 수행 결과를 gist에 업로드하고 서로의 gist를 리뷰하면서 매번 clone해서 테스트해보는 것에서 번거로움을 느꼈었다. -⇒ 따라서 쉽게 gist의 실행결과를 확인할 수 있는 서비스가 도움이 될 것이라고 생각했다. -- 네이버 부스트 캠퍼들이 gist를 이용한 학습 과정에서 활용하기 좋은 주제라고 생각했다 +부스트캠프 챌린지 과정에서는 매일 도전 과제를 수행하며, 동시에 그룹원들의 과제를 Gist를 통해 확인하고 피드백하는 시간을 가집니다. + +이 과정에서 캠퍼들은 Gist 경로를 찾아 로컬 환경에 클론(clone)한 뒤 실행하는 일련의 과정을 반복하게 되며, 특히 익숙하지 않은 도구를 사용하면서 이를 수행할 때 여러 가지 개발 환경 문제를 겪게 됩니다. + +Froxy는 이러한 반복적인 코드 확인과 실행 과정을 보다 간편하고 효율적으로 지원하기 위해 만들었습니다. Froxy는 캠퍼들이 실행 환경에서 발생하는 문제를 줄이고, 코드 리뷰와 피드백에 더 집중할 수 있는 환경을 제공합니다. 이를 통해 캠퍼들이 과제 수행과 코드 리뷰에 있어 생산성을 높이고, 학습 과정에 더 몰입할 수 있도록 돕는 것이 Froxy의 목표입니다. + + + + + + +
+ image +
검색하기 어려운 표
+
+ image +
그룹원을 태그나 제목으로 검색
+
+ +## 🐸 주요 기능 + +### Gist 코드를 빠르게 게시하기 + +> 사용자의 Gist 코드를 쉽고 빠르게 게시할 수 있게 만들었습니다. + +![생성](https://github.com/user-attachments/assets/deee5eb7-d79b-46fd-8f20-f3ab823f55ba) + +### 다른 사람의 Gist 확인하기 + +> 다른 캠퍼가 올린 Gist를 빠르게 찾을 수 있습니다. + +![목록](https://github.com/user-attachments/assets/90b33f0b-4ee5-481e-bfd7-2c0819d9be4d) + +### Gist 코드를 빠르게 실행하기 + +> 코드의 입력값 제공해 빠르게 실행할 수 있습니다. + +![실행](https://github.com/user-attachments/assets/4b09df35-2d04-4976-bf82-d282e4a7a794) + +## 🧑🏻‍💻 FE 기술적 도전 + +Froxy의 프론트엔드에서는 서버 데이터를 효과적으로 UI로 전달하는 것이 핵심 과제였습니다. 이를 위해 아래와 같은 기술적 도전에 집중했습니다. + +### QueryKey Factory로 QueryKey 구조화 하기 + +> [Effective QueryKey](https://github.com/boostcampwm-2024/web38-Froxy/wiki/%5B%EB%AF%BC%EC%9A%B0%5DEffective-Query-Key) + +- Tanstack Query를 사용해 서버상태를 관리하고 있었기 때문에 각 도메인마다 **구조화된 QueryKey가** 필요했습니다. +- 이를 위해, API 계층에서 사용하는 fetch 함수를 인자로 받아 **QueryKey와 QueryFunction을 함께 동적으로 생성하는 QueryKey Factory를 구현**했습니다. +- 이를 통해 프론트 코드 전역에서 **구조화된 QueryOption들을 선언적으로 재사용**할 수 있었습니다. + +### 계층화와 도메인 모델 + +> [나도 써 본 잘알려진 UI 패턴(프론트엔드 계층화, 도메인 모델 객체 활용)]() -

+- 프론트엔드 코드의 유지보수성을 높이기 위해 계층화된 아키텍처를 도입했습니다. +- 각각의 계층(API, Query, Hook, UI)를 통해 추가 요구사항이 생겼을 때 필요한 코드를 **계층으로 분리해 독립적으로 개발**할 수 있었습니다. +- 또한 **도메인 모델**을 설계해 각각에 도메인에 필요한 **비즈니스 로직을 캡슐화**해 UI에서 불필요한 비즈니스 로직을 선언하지 않고 UI로직만 관리할 수 있도록 했습니다. -## ⭐️ To Be -- 스터디 그룹원의 코드를 clone하지 않고 체크포인트를 점검할 수 있다. -- 여러 캠퍼들의 결과들을 실행시키지 않고도 확인하여 코드리뷰 역량이 향상되기를 바란다. +```mermaid +sequenceDiagram + participant UI + participant Hooks + participant Query + participant API -

+ UI->>Hooks: useCustomHook 호출 + Hooks->>Query: 데이터 쿼리 요청 (fetch or mutate) + alt Cache HIT + Query->>Query: 캐시 데이터 반환 + Query-->>Hooks: 도메인 Model과 쿼리 옵션 전달 + else Cache MISS + Query->>API: HTTP 요청 전송 (Axios, fetch 등) + API-->>Query: 응답 데이터 반환 후 Model로 래핑 + Query-->>Hooks: 도메인 Model과 쿼리 옵션 전달 + end + Hooks-->>UI: 도메인 Model과 상태 전달 + UI->>UI: UI 업데이트 +``` + +### MSW로 개발 효율화하기 + +> [MSW로 개발 효율화하기](https://github.com/boostcampwm-2024/web38-Froxy/wiki/MSW) + +- **MSW**를 이용해서 백엔드의 API 개발이 완료되지 않은 상태에서 **API 호출과 관련된 시나리오를 테스트**할 수 있었습니다. +- MockRepository를 이용해 요청 조건에 따라 데이터를 **동적으로 응답**하도록 구현했습니다. +- 이를 통해 인증이 필요한 요청에 대한 로직이나 tanstack query를 미리 적용해보고 테스트해볼 수 있어서 나중에 실제 API를 연결할 때 빠르게 진행할 수 있었습니다. + +### Suspense, ErrorBoundary로 Fallback 구현하기 + +> [Suspense, ErrorBoundary로 Fallback 구현하기](https://github.com/boostcampwm-2024/web38-Froxy/wiki/Suspense+ErrorBoundary) + + + + + + + + +
+ 로딩 시 Fallback UI +
+ 로딩 시 Fallback UI +
+ 에러 시 Fallback UI +
+ 에러 시 Fallback UI +
+ +- 리액트를 선언적으로 사용하기 위해서 **로딩 상태는 Suspense**가, **에러 상태는 ErrorBoundary**가 관리하도록 역할을 분리하고자 했습니다. +- Layout Shift 문제 방지와 사용자 경험 개선을 위해서 **Skeleton UI**를 사용해서 로딩 시 fallback UI를 구현했습니다. +- ErrorBoundary와 tanstack query의 **useQueryErrorResetBoundary()** 훅을 이용해서 오류 발생 시 **재시도**가 가능하도록 에러 fallback UI를 구현했습니다. + +## 🧑🏻‍💻 BE 기술적 도전 + +### docker를 이용한 코드 실행 + +> [dockerode를 이용한 컨테이너 관리](https://github.com/boostcampwm-2024/web38-Froxy/wiki/Dockerode%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%82%AC%EC%9A%A9) + +- 쉘을 이용한 방법보다는 nest에서 직접 docker를 관리하고자 했습니다. +- git clone, image build보다 빠른 속도를 위해 컨테이너에 직접 파일을 parsing, 삽입하도록 구현했습니다. +- 컨테이너와 소켓을 통해 입출력 결과를 반환할 수 있습니다. + +### queue & pool을 이용한 스케줄링 + +> [Redis-queue를 이용한 컨테이너 스케줄링](https://github.com/boostcampwm-2024/web38-Froxy/wiki/redis%E2%80%90queue%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%A7%81) + +- queue를 이용해 요청이 순차적으로 처리되도록 했습니다. +- container pool을 직접 관리하여 리소스 사용률을 감소시켰습니다. + +```mermaid +graph LR + API[API 요청] -->|요청 추가| RedisQueue[Redis Queue] + RedisQueue -->|작업 요청| DockerService[Docker 서비스] + + DockerService -->|컨테이너 요청| Pool[컨테이너 Pool] + Pool -->|할당된 컨테이너| Container[컨테이너] + Container -->|작업 실행| Task[js 실행] + Task -->|결과 반환| Container + Container -->|컨테이너 반납| Pool + Container -->|작업 결과| Response[API 응답] + DockerService -->|결과 반환| RedisQueue + + +``` + +### Octokit 클라이언트 대신 직접 Gist API 모듈화 + +> [사용자 지정 Gist API 파싱 모듈](https://github.com/boostcampwm-2024/web38-Froxy/wiki/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A7%80%EC%A0%95-gist-api-%ED%8C%8C%EC%8B%B1-%EB%AA%A8%EB%93%88) + +- GitHub 공식 client Octokit 모듈 사용이 nest와의 호환성 문제로 인하여 REST API를 통해 모듈을 직접 구현했습니다. +- 이때 type-safe한 환경을 만들기 위해서 응답과 요청에 대한 type 추론이 가능하도록 설계했습니다. +- 이는TypeScript의 장점을 최대한 활용할 수 있도록 dto로 파싱하여 응답을 필터링하고 유효성 검사를 할 수 있게 되었습니다. + +### TypeORM을 통한 다대다 테이블 관리 + +> [typeORM 다대다 테이블 트러블 슈팅](https://github.com/boostcampwm-2024/web38-Froxy/wiki/typeORM-%EB%8B%A4%EB%8C%80%EB%8B%A4-%EC%86%8D%EC%84%B1-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85) + +- update 함수를 쓰는 과정에서 **다대다 관계**의 데이터는 관계 테이블을 통해 연결되기 때문에, 직접 필드로 접근해 조건을 걸 수 없는 문제를 발견했습니다. +- 다대다 관계를 사용하는 repository에서 update 사용 시, 쉽게 보이는 오류이며 save로 임시로 오류를 막았으나 find 및 save로 인한 원자성 해침을 막기 위한 방침이 필요했습니다. +- 또한 tag 미사용 시 데이터를 자동으로 삭제하는 기능을 위해서, tag-lotus relation 테이블을 추가하여 one-many 관계로 분리하였습니다. + +## 🔧 기술 스택 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
분류기술 스택
+

공통

+
+ +
+

프론트엔드

+
+ + + + + +
+

백엔드

+
+ + + + + +
+

패키지 매니저

+
+ + +
+

배포

+
+ + + + +
+

협업

+
+ + + +
## 🔧 프로젝트 아키텍처 + ![image](https://github.com/user-attachments/assets/3b99d8b7-84e7-4555-a397-25757a067f2e) -## 🔧 실행 흐름 -![sequence](https://github.com/user-attachments/assets/15b83e30-7036-456f-aad8-7c2fd5d48d15) +## 🔧 프로젝트 flow + +![flow](https://github.com/user-attachments/assets/49847a33-5851-4e6c-b109-689156b1a32f) ## 👥 팀원 @@ -54,7 +329,3 @@ Gist에서 코드를 복제하고 실행 환경을 설정하는 번거로움 없 BE - -## [📚 팀 노션](https://freckle-calliandra-79a.notion.site/Team38-F-Rog-12d9038c617380509fbdf4eb928e4238) - -## [🐸 froxy 배포주소](http://www.frog-froxy.site) diff --git a/apps/backend/package.json b/apps/backend/package.json index d2025e02..e5cafc30 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,91 +1,91 @@ -{ - "name": "backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "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:e2e": "jest --config ./test/jest-e2e.json" - }, - "lint-staged": { - "*.ts": [ - "eslint --fix", - "prettier --write" - ] - }, - "dependencies": { - "@nestjs/bull": "^10.2.2", - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/jwt": "^10.2.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/swagger": "^8.0.7", - "@nestjs/typeorm": "^10.0.2", - "bull": "^4.16.4", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dockerode": "^4.0.2", - "dotenv": "^16.4.5", - "mysql2": "^3.11.4", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "swagger-ui-express": "^5.0.1", - "tar-stream": "^3.1.7", - "typeorm": "^0.3.20" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/config": "^3.3.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/supertest": "^2.0.12", - "@typescript-eslint/eslint-plugin": "^5.59.11", - "@typescript-eslint/parser": "^5.59.11", - "eslint": "^8.42.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-no-relative-import-paths": "^1.5.5", - "eslint-plugin-prettier": "^4.2.1", - "jest": "^29.5.0", - "prettier": "^2.8.8", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - } -} +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "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:e2e": "jest --config ./test/jest-e2e.json" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write" + ] + }, + "dependencies": { + "@nestjs/bull": "^10.2.2", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^8.0.7", + "@nestjs/typeorm": "^10.0.2", + "bull": "^4.16.4", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dockerode": "^4.0.2", + "dotenv": "^16.4.5", + "mysql2": "^3.11.4", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", + "tar-stream": "^3.1.7", + "typeorm": "^0.3.20" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/config": "^3.3.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^5.59.11", + "@typescript-eslint/parser": "^5.59.11", + "eslint": "^8.42.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-no-relative-import-paths": "^1.5.5", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "prettier": "^2.8.8", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 2981e2f8..d336ec60 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,42 +1,42 @@ -import { BullModule } from '@nestjs/bull'; -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { AuthModule } from './auth/auth.module'; -import { queueConfig } from './config/queue.config'; -import { typeORMConfig } from './config/typeorm.config'; -import { DockerModule } from './docker/docker.module'; -import { GistModule } from './gist/gist.module'; -import { HistoryModule } from './history/history.module'; -import { LotusModule } from './lotus/lotus.module'; -import { TagModule } from './tag/tag.module'; -import { UserModule } from './user/user.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env' - }), - TypeOrmModule.forRootAsync({ - inject: [ConfigService], - useFactory: typeORMConfig - }), - BullModule.forRootAsync({ - inject: [ConfigService], - useFactory: queueConfig - }), - DockerModule, - GistModule, - HistoryModule, - UserModule, - AuthModule, - LotusModule, - TagModule - ], - controllers: [AppController], - providers: [AppService] -}) -export class AppModule {} +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { queueConfig } from './config/queue.config'; +import { typeORMConfig } from './config/typeorm.config'; +import { DockerModule } from './docker/docker.module'; +import { GistModule } from './gist/gist.module'; +import { HistoryModule } from './history/history.module'; +import { LotusModule } from './lotus/lotus.module'; +import { TagModule } from './tag/tag.module'; +import { UserModule } from './user/user.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env' + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: typeORMConfig + }), + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: queueConfig + }), + DockerModule, + GistModule, + HistoryModule, + UserModule, + AuthModule, + LotusModule, + TagModule + ], + controllers: [AppController], + providers: [AppService] +}) +export class AppModule {} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 8daf9c2e..708166a5 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,20 +1,20 @@ -import { Global, Module, forwardRef } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { AuthService } from './auth.service'; -import { UserModule } from '@/user/user.module'; - -@Global() -@Module({ - imports: [ - JwtModule.register({ - signOptions: { - algorithm: 'HS256', - expiresIn: '1h' - } - }), - forwardRef(() => UserModule) - ], - providers: [AuthService], - exports: [AuthService] -}) -export class AuthModule {} +import { Global, Module, forwardRef } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { UserModule } from '@/user/user.module'; + +@Global() +@Module({ + imports: [ + JwtModule.register({ + signOptions: { + algorithm: 'HS256', + expiresIn: '1h' + } + }), + forwardRef(() => UserModule) + ], + providers: [AuthService], + exports: [AuthService] +}) +export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index d1defa14..06949828 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,58 +1,58 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; -import { isString } from 'class-validator'; -import { Request } from 'express'; -import { UserService } from '@/user/user.service'; - -@Injectable() -export class AuthService { - constructor( - private readonly userService: UserService, - private configService: ConfigService, - private jwtService: JwtService - ) {} - - private JWT_SECRET_KEY = this.configService.get('JWT_SECRET_KEY'); - createJwt(userId: string): string { - const payload = { userId }; - return this.jwtService.sign(payload, { - secret: this.JWT_SECRET_KEY - }); - } - - verifyJwt(token: string): string { - try { - if (!token) { - throw new Error('no token'); - } - const decoded = this.jwtService.verify(token, { - secret: this.JWT_SECRET_KEY - }); - if (!decoded.userId) throw new Error('invalid token'); - return decoded.userId; - } catch (e) { - if (e.name === 'TokenExpiredError') throw new HttpException('token expired', HttpStatus.UNAUTHORIZED); - else if (e.message === 'no token') throw new HttpException('token is not found', HttpStatus.UNAUTHORIZED); - else { - throw new HttpException('invalid token', HttpStatus.UNAUTHORIZED); - } - } - } - - getIdFromRequest(req: Request): string { - const auth = req.header('Authorization'); - if (!auth) { - throw new HttpException('token is not found', HttpStatus.UNAUTHORIZED); - } - const token = req.header('Authorization').split(' ')[1].trim(); - if (!isString(token)) { - throw new HttpException('invalid token', HttpStatus.UNAUTHORIZED); - } - return this.verifyJwt(token); - } - - async getUserGitToken(userId: string): Promise { - return await this.userService.findUserGistToken(userId); - } -} +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { isString } from 'class-validator'; +import { Request } from 'express'; +import { UserService } from '@/user/user.service'; + +@Injectable() +export class AuthService { + constructor( + private readonly userService: UserService, + private configService: ConfigService, + private jwtService: JwtService + ) {} + + private JWT_SECRET_KEY = this.configService.get('JWT_SECRET_KEY'); + createJwt(userId: string): string { + const payload = { userId }; + return this.jwtService.sign(payload, { + secret: this.JWT_SECRET_KEY + }); + } + + verifyJwt(token: string): string { + try { + if (!token) { + throw new Error('no token'); + } + const decoded = this.jwtService.verify(token, { + secret: this.JWT_SECRET_KEY + }); + if (!decoded.userId) throw new Error('invalid token'); + return decoded.userId; + } catch (e) { + if (e.name === 'TokenExpiredError') throw new HttpException('token expired', HttpStatus.UNAUTHORIZED); + else if (e.message === 'no token') throw new HttpException('token is not found', HttpStatus.UNAUTHORIZED); + else { + throw new HttpException('invalid token', HttpStatus.UNAUTHORIZED); + } + } + } + + getIdFromRequest(req: Request): string { + const auth = req.header('Authorization'); + if (!auth) { + throw new HttpException('token is not found', HttpStatus.UNAUTHORIZED); + } + const token = req.header('Authorization').split(' ')[1].trim(); + if (!isString(token)) { + throw new HttpException('invalid token', HttpStatus.UNAUTHORIZED); + } + return this.verifyJwt(token); + } + + async getUserGitToken(userId: string): Promise { + return await this.userService.findUserGistToken(userId); + } +} diff --git a/apps/backend/src/comment/comment.entity.ts b/apps/backend/src/comment/comment.entity.ts index fca54cd0..49cb72e2 100644 --- a/apps/backend/src/comment/comment.entity.ts +++ b/apps/backend/src/comment/comment.entity.ts @@ -17,11 +17,11 @@ export class Comment { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; - @ManyToOne(() => User, (user) => user.comments) + @ManyToOne(() => User, (user) => user.comments, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) // 외래키 이름 설정 user: User; - @ManyToOne(() => Lotus, (lotus) => lotus.comments) + @ManyToOne(() => Lotus, (lotus) => lotus.comments, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'lotus_id' }) // 외래키 이름 설정 lotus: Lotus; } diff --git a/apps/backend/src/config/queue.config.ts b/apps/backend/src/config/queue.config.ts index 95b4d79a..2575853e 100644 --- a/apps/backend/src/config/queue.config.ts +++ b/apps/backend/src/config/queue.config.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; export const queueConfig = (configService: ConfigService): BullRootModuleOptions => ({ redis: { host: configService.get('REDIS_HOST', { infer: true }), - port: configService.get('REDIS_PORT', { infer: true }) + port: configService.get('REDIS_PORT', { infer: true }), + password: configService.get('REDIS_PASSWORD', { infer: true }) } }); diff --git a/apps/backend/src/config/typeorm.config.ts b/apps/backend/src/config/typeorm.config.ts index d6962a4b..559437c3 100644 --- a/apps/backend/src/config/typeorm.config.ts +++ b/apps/backend/src/config/typeorm.config.ts @@ -1,18 +1,20 @@ -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { Comment } from '@/comment/comment.entity'; -import { History } from '@/history/history.entity'; -import { Lotus } from '@/lotus/lotus.entity'; -import { Tag } from '@/tag/tag.entity'; -import { User } from '@/user/user.entity'; - -export const typeORMConfig = async (configService: ConfigService): Promise => ({ - type: 'mysql', - host: configService.get('MYSQL_HOST'), - port: configService.get('MYSQL_PORT'), - username: configService.get('MYSQL_USER'), - password: configService.get('MYSQL_PASSWORD'), - database: configService.get('MYSQL_DATABASE'), - entities: [User, Lotus, Comment, Tag, History], - synchronize: true //todo: env로 release에서는 false가 되도록 해야함 -}); +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { Comment } from '@/comment/comment.entity'; +import { History } from '@/history/history.entity'; +import { Lotus } from '@/lotus/lotus.entity'; +import { LotusTag } from '@/relation/lotus.tag.entity'; +import { Tag } from '@/tag/tag.entity'; +import { User } from '@/user/user.entity'; + +export const typeORMConfig = async (configService: ConfigService): Promise => ({ + type: 'mysql', + host: configService.get('MYSQL_HOST'), + port: configService.get('MYSQL_PORT'), + username: configService.get('MYSQL_USER'), + password: configService.get('MYSQL_PASSWORD'), + database: configService.get('MYSQL_DATABASE'), + entities: [User, Lotus, Comment, Tag, History, LotusTag] + //dropSchema: true, + //synchronize: true //todo: env로 release에서는 false가 되도록 해야함 +}); diff --git a/apps/backend/src/constants/constants.ts b/apps/backend/src/constants/constants.ts index b3b274dd..1fa1b642 100644 --- a/apps/backend/src/constants/constants.ts +++ b/apps/backend/src/constants/constants.ts @@ -16,4 +16,4 @@ export enum SUPPORTED_LANGUAGES { JS = '.js' } -export const MAX_CONTAINER_CNT = 6; +export const MAX_CONTAINER_CNT = 10; diff --git a/apps/backend/src/docker/docker.consumer.ts b/apps/backend/src/docker/docker.consumer.ts index d89ebaeb..fd7e4650 100644 --- a/apps/backend/src/docker/docker.consumer.ts +++ b/apps/backend/src/docker/docker.consumer.ts @@ -1,6 +1,7 @@ -import { Process, Processor } from '@nestjs/bull'; +import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { Job } from 'bull'; +import { error } from 'console'; import { Container } from 'dockerode'; import * as tar from 'tar-stream'; import { DockerContainerPool } from './docker.pool'; @@ -24,13 +25,14 @@ interface GistFile { attr: GistFileAttributes; } -@Processor('docker-queue') +@Processor('froxy-queue') @Injectable() export class DockerConsumer { + queue_num = false; constructor(private gistService: GistService, private dockerContainerPool: DockerContainerPool) {} - @Process({ name: 'docker-run', concurrency: MAX_CONTAINER_CNT }) - async handleDockerRun(job: Job) { + @Process({ name: 'dynamic-docker-run' }) + async dynamicDockerRun(job: Job) { const { gitToken, gistId, commitId, mainFileName, inputs } = job.data; let container; try { @@ -41,10 +43,43 @@ export class DockerConsumer { } catch (error) { throw new Error(`Execution failed: ${error.message}`); } finally { - await this.dockerContainerPool.returnContainer(container); + await this.cleanWorkDir(container); + this.dockerContainerPool.returnContainer(container); } } + @Process({ name: 'always-docker-run', concurrency: MAX_CONTAINER_CNT }) + async alwaysDockerRun(job: Job) { + const { gitToken, gistId, commitId, mainFileName, inputs, c } = job.data; + let container; + try { + container = await this.dockerContainerPool.getContainer(); + const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); + await this.cleanWorkDir(container); + this.dockerContainerPool.pool.push(container); + return result; + } catch (error) { + await this.cleanWorkDir(container); + this.dockerContainerPool.pool.push(container); + throw new Error(`Execution failed: ${error.message}`); + } + } + + @Process({ name: 'multipleIO-docker-run', concurrency: MAX_CONTAINER_CNT }) + async multipleIODockerRun(job: Job) { + const { gitToken, gistId, commitId, mainFileName, inputs, c } = job.data; + let container; + try { + container = await this.dockerContainerPool.pool[0]; + await this.initWorkDir(container, c); + const result = await this.runGistFiles(container, gitToken, gistId, commitId, mainFileName, inputs); + await this.cleanWorkDir(container); + return result; + } catch (error) { + await this.cleanWorkDir(container); + throw new Error(`Execution failed: ${error}`); + } + } async runGistFiles( container: Container, gitToken: string, @@ -60,36 +95,61 @@ export class DockerConsumer { } //desciption: 컨테이너 시작 const tarBuffer = await this.parseTarBuffer(files); - //desciption: tarBuffer를 Docker 컨테이너에 업로드 - await container.putArchive(tarBuffer, { path: '/tmp' }); + await container.putArchive(tarBuffer, { path: `/tmp` }); if (files.some((file) => file.fileName === 'package.json')) { await this.packageInstall(container); } - const stream = await this.dockerExcution(inputs, mainFileName, container); + const exec = await this.dockerExcution(inputs, mainFileName, container); + console.log('dockerExcution'); let output = ''; - - setTimeout(async () => { - stream.end(); - }, 5000); - //desciption: 스트림 종료 후 결과 반환 + const stream = await exec.start({ hijack: true, stdin: true }); + console.log('exec.start'); return new Promise((resolve, reject) => { - //desciption: 스트림에서 데이터 수집 + let time = null; + + const onStreamClose = async () => { + try { + let result = await this.filterAnsiCode(output); + clearTimeout(time); + if (inputs.length !== 0) { + result = result.split('\n').slice(1).join('\n'); + } + resolve(result); + } catch (err) { + reject(err); + } + }; + + // Timeout 설정 + time = setTimeout(() => { + stream.destroy(new Error('Timeout')); + }, 10000); + + // 스트림에서 데이터 수집 stream.on('data', (chunk) => { output += chunk.toString(); }); - stream.on('end', async () => { - await container.remove({ force: true }); - let result = this.filterAnsiCode(output); - if (inputs.length !== 0) { - result = result.split('\n').slice(1).join('\n'); - } - - this.initWorkDir(container); - resolve(result); + // 스트림 종료 대기 + stream.on('close', onStreamClose); + stream.on('end', onStreamClose); + stream.on('error', (err) => { + reject(err); }); - stream.on('error', reject); + + (async () => { + try { + for (const input of inputs) { + if (!stream.destroyed && stream.writable) { + await stream.write(input + '\n'); + } + await this.delay(100); //각 입력 term + } + } catch (err) { + reject(err); + } + })(); }); } @@ -143,16 +203,10 @@ export class DockerConsumer { AttachStderr: true, Tty: inputs.length !== 0, //true Cmd: ['node', mainFileName], - workingDir: '/tmp' + workingDir: `/tmp` }); - //todo: 입력값이 없으면 스킵 - const stream = await exec.start({ hijack: true, stdin: true }); - for (const input of inputs) { - await stream.write(input + '\n'); - await this.delay(100); //각 입력 term - } - // stream.end(); - return stream; + + return exec; } async packageInstall(container: Container): Promise { @@ -161,7 +215,7 @@ export class DockerConsumer { AttachStdout: true, AttachStderr: true, Cmd: ['npm', 'install'], - workingDir: '/tmp' + workingDir: `/tmp` }); const stream = await exec.start(); @@ -174,15 +228,14 @@ export class DockerConsumer { }); } - async initWorkDir(container: Container): Promise { + async cleanWorkDir(container: Container): Promise { try { const exec = await container.exec({ AttachStdin: false, AttachStdout: true, AttachStderr: true, - Cmd: ['rm', '-rf', '/tmp/*'] + Cmd: ['sh', '-c', `rm -rf /tmp`] }); - const stream = await exec.start(); return new Promise((resolve, reject) => { stream.on('data', (chunk) => { @@ -192,6 +245,32 @@ export class DockerConsumer { stream.on('error', reject); }); } catch (error) { + console.log(error.message); + throw new Error('container tmp init failed'); + } + } + + async initWorkDir(container: Container, dirId: any): Promise { + try { + const exec = await container.exec({ + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Cmd: ['mkdir', `/tmp/${dirId}`] + }); + const stream = await exec.start(); + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + const c = chunk; + }); + stream.on('end', () => { + console.log(`${dirId}번째 디렉토리 생성`); + resolve(); + }); + stream.on('error', reject); + }); + } catch (error) { + console.log(error.message); throw new Error('container tmp init failed'); } } diff --git a/apps/backend/src/docker/docker.controller.ts b/apps/backend/src/docker/docker.controller.ts index e7486d32..d664e76b 100644 --- a/apps/backend/src/docker/docker.controller.ts +++ b/apps/backend/src/docker/docker.controller.ts @@ -8,39 +8,66 @@ export class DockerController { @Get('get') async getDockersTest(): Promise { - const mainFileName = 'FunctionDivide.js'; - // const gitToken = this.configService.get('STATIC_GIST_ID'); - const gistId = this.configService.get('DYNAMIC_GIST_ID'); - const gitToken = this.configService.get('GIT_TOKEN'); - const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; - const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; - console.log('docker Test'); - const value = await this.dockerProducer.getDocker( - gitToken, - '25cf4713b2386b4ad4ce7c8dbbecebe8', - 'e717102aefed1f1f8b27b63eb7f46ce1f1516c86', - 'main.js', - inputs - ); - return value; + try { + const mainFileName = 'FunctionDivide.js'; + // const gitToken = this.configService.get('STATIC_GIST_ID'); + const gistId = this.configService.get('DYNAMIC_GIST_ID'); + const gitToken = this.configService.get('GIT_TOKEN'); + const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; + const value = await this.dockerProducer.getDocker( + gitToken, + '25cf4713b2386b4ad4ce7c8dbbecebe8', + 'e717102aefed1f1f8b27b63eb7f46ce1f1516c86', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } } @Get('get2') async getDockersTest2(): Promise { - const mainFileName = 'FunctionDivide.js'; - // const gitToken = this.configService.get('STATIC_GIST_ID'); - const gistId = this.configService.get('DYNAMIC_GIST_ID'); + try { + const gitToken = this.configService.get('GIT_TOKEN'); + const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + const value = await this.dockerProducer.getDocker( + gitToken, + '7f93da28e2522409a2274eff51b5dc20', + '57944932d1ec6f05415b5e067f23c8a358e79d84', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } + } + + @Get('get3') + async getDockersTest3(): Promise { + //무한루프 const gitToken = this.configService.get('GIT_TOKEN'); const inputs = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; - const commit = '654dd3f1d7f17d172132aebae283e73356197d18'; - console.log('docker Test2'); - const value = await this.dockerProducer.getDocker( - gitToken, - '7f93da28e2522409a2274eff51b5dc20', - '57944932d1ec6f05415b5e067f23c8a358e79d84', - 'main.js', - inputs - ); - return value; + try { + const value = await this.dockerProducer.getDocker( + gitToken, + '2574b42a40e9ea6d35a9434a88694720', + '2b98cc9dd44bf0c8ddf43a715d2443d7261e25fc', + 'main.js', + inputs + ); + console.log(value); + return value; + } catch (e) { + console.error(e.message); + return e.message; + } } } diff --git a/apps/backend/src/docker/docker.module.ts b/apps/backend/src/docker/docker.module.ts index 1fe90d21..3f8b1652 100644 --- a/apps/backend/src/docker/docker.module.ts +++ b/apps/backend/src/docker/docker.module.ts @@ -1,26 +1,20 @@ -import { BullModule } from '@nestjs/bull'; -import { Module } from '@nestjs/common'; -import { DockerConsumer } from './docker.consumer.js'; -import { DockerController } from './docker.controller.js'; -import { DockerContainerPool } from './docker.pool.js'; -import { DockerProducer } from './docker.producer.js'; -import { GistModule } from '@/gist/gist.module'; - -@Module({ - imports: [ - GistModule, - BullModule.forRoot({ - redis: { - host: '211.188.48.24', // Redis 호스트 주소 - port: 6379 // Redis 포트 - } - }), - BullModule.registerQueue({ - name: 'docker-queue' // 큐 이름 - }) - ], - controllers: [DockerController], - providers: [DockerProducer, DockerConsumer, DockerContainerPool], - exports: [DockerProducer] -}) -export class DockerModule {} +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; +import { DockerConsumer } from './docker.consumer.js'; +import { DockerController } from './docker.controller.js'; +import { DockerContainerPool } from './docker.pool.js'; +import { DockerProducer } from './docker.producer.js'; +import { GistModule } from '@/gist/gist.module'; + +@Module({ + imports: [ + GistModule, + BullModule.registerQueue({ + name: 'froxy-queue' + }) + ], + controllers: [DockerController], + providers: [DockerProducer, DockerConsumer, DockerContainerPool], + exports: [DockerProducer] +}) +export class DockerModule {} diff --git a/apps/backend/src/docker/docker.pool.ts b/apps/backend/src/docker/docker.pool.ts index 781a6170..e49cb309 100644 --- a/apps/backend/src/docker/docker.pool.ts +++ b/apps/backend/src/docker/docker.pool.ts @@ -5,14 +5,45 @@ import { MAX_CONTAINER_CNT } from '@/constants/constants'; @Injectable() export class DockerContainerPool implements OnApplicationBootstrap { - docker = new Docker(); + docker = new Docker({ socketPath: '/var/run/docker.sock' }); pool: Container[] = []; lock = false; async onApplicationBootstrap() { - await this.createContainer(); + await this.clearContainer(); + await this.createAlwaysContainer(); } - async createContainer() { + async clearContainer() { + const containersToDelete = await this.docker.listContainers({ all: true }); + await Promise.all( + containersToDelete + .filter((container) => container.Names.some((name) => name.startsWith('/froxy-run'))) + .map(async (container) => { + const removeContainer = await this.docker.getContainer(container.Id); + await removeContainer.remove({ force: true }); + }) + ); + } + async createDynamicContainer() { + for (let i = 0; i < MAX_CONTAINER_CNT; i++) { + const container = await this.docker.createContainer({ + Image: 'node:latest', + Tty: false, + OpenStdin: true, + AttachStdout: true, + AttachStderr: true, + Env: ['NODE_DISABLE_COLORS=true', 'TERM=dumb'], + name: `froxy-run${i + 1}`, + HostConfig: { + Memory: 1024 * 1024 * 1024, + MemorySwap: 1024 * 1024 * 1024 + } + }); + this.pool.push(container); + } + } + + async createAlwaysContainer() { for (let i = 0; i < MAX_CONTAINER_CNT; i++) { const container = await this.docker.createContainer({ Image: 'node:latest', @@ -23,12 +54,37 @@ export class DockerContainerPool implements OnApplicationBootstrap { Env: [ 'NODE_DISABLE_COLORS=true', // 색상 비활성화 'TERM=dumb' // dumb 터미널로 설정하여 색상 비활성화 - ] + ], + name: `froxy-run${i + 1}`, + HostConfig: { + Memory: 1024 * 1024 * 1024, + MemorySwap: 1024 * 1024 * 1024, + networkMode: 'host' + } }); + container.start(); + this.pool.push(container); } } + async createSingleContainer() { + const container = await this.docker.createContainer({ + Image: 'node:latest', + Tty: false, + OpenStdin: true, + AttachStdout: true, + AttachStderr: true, + Env: [ + 'NODE_DISABLE_COLORS=true', // 색상 비활성화 + 'TERM=dumb' // dumb 터미널로 설정하여 색상 비활성화 + ], + name: `single-run` + }); + container.start(); + this.pool.push(container); + } + async getContainer(): Container | null { while (this.lock || this.pool.length === 0) { await this.delay(10); // 풀 비어 있음 처리 diff --git a/apps/backend/src/docker/docker.producer.ts b/apps/backend/src/docker/docker.producer.ts index 9348c0a0..45011bd6 100644 --- a/apps/backend/src/docker/docker.producer.ts +++ b/apps/backend/src/docker/docker.producer.ts @@ -1,14 +1,19 @@ import { InjectQueue } from '@nestjs/bull'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { Queue } from 'bull'; import { DockerContainerPool } from './docker.pool'; @Injectable() -export class DockerProducer { +export class DockerProducer implements OnApplicationBootstrap { + cnt = 0; constructor( - @InjectQueue('docker-queue') - private readonly dockerQueue, + @InjectQueue('froxy-queue') + private readonly dockerQueue: Queue, private dockerContainerPool: DockerContainerPool ) {} + onApplicationBootstrap() { + this.dockerQueue.setMaxListeners(1000); + } async getDocker( gitToken: string, @@ -17,25 +22,29 @@ export class DockerProducer { mainFileName: string, inputs: any[] ): Promise { - const job = await this.dockerQueue.add( - 'docker-run', - { - gitToken, - gistId, - commitId, - mainFileName, - inputs - }, - { removeOnComplete: true, removeOnFail: true } - ); - // Job 완료 대기 및 결과 반환 - return new Promise((resolve, reject) => { - job - .finished() - .then((result) => { - resolve(result); - }) - .catch((error) => reject(error)); - }); + this.cnt++; + const c = this.cnt; + try { + const job = await this.dockerQueue.add( + 'multipleIO-docker-run', + { + gitToken, + gistId: gistId, + commitId: commitId, + mainFileName, + inputs, + c + }, + { + jobId: `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`, + removeOnComplete: true, + removeOnFail: true + } + ); + console.log('jobId추가 :', job.id); + return await job.finished(); + } catch (error) { + throw error; + } } } diff --git a/apps/backend/src/gist/gist.service.ts b/apps/backend/src/gist/gist.service.ts index 65cbbe28..ae6707cc 100644 --- a/apps/backend/src/gist/gist.service.ts +++ b/apps/backend/src/gist/gist.service.ts @@ -1,188 +1,188 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { CommentDto } from './dto/comment.dto'; -import { CommitDto } from './dto/gist.commit.dto'; -import { GistApiFileDto } from './dto/gistApiFile.dto'; -import { GistApiFileListDto } from './dto/gistApiFileList.dto'; -import { ResponseAllGistsDto } from './dto/response.allGists.dto'; -import { ResponseGistDto } from './dto/response.gist.dto'; -import { UserDto } from './dto/user.dto'; -import { GIST_AUTH_HEADER } from '@/constants/constants'; - -@Injectable() -export class GistService { - gitBaseUrl: string; - constructor() { - this.gitBaseUrl = 'https://api.github.com/'; - } - async getGistList(gitToken: string, page: number, perPage: number): Promise { - let hasNextPage = true; - const currentGistPage = await this.gistPageData(gitToken, page, perPage); - const nextGistPage = await this.gistPageData(gitToken, page + 1, perPage); - - if (nextGistPage.length === 0) { - hasNextPage = false; - } - - const gists = currentGistPage.map((gist) => { - return ResponseGistDto.of(gist); - }); - return ResponseAllGistsDto.of(gists, page, hasNextPage); - } - - async gistPageData(gitToken: string, page: number, per_page: number): Promise { - const params = { - page: page.toString(), - per_page: per_page.toString() - }; - const queryParam = new URLSearchParams(params).toString(); - const gistsData = await this.gistReq('GET', `${this.gitBaseUrl}gists`, gitToken, queryParam); - return await gistsData.json(); - } - - async getGistById(id: string, gitToken: string): Promise { - let response = null; - try { - response = await this.gistReq('GET', `${this.gitBaseUrl}gists/${id}`, gitToken); - } catch (e) { - throw new HttpException('gistId is not exist', HttpStatus.NOT_FOUND); - } - const data = await response.json(); - - const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(data, gitToken); - return GistApiFileListDto.of(data, fileArr); - } - - async getMostRecentGistInUser(gitToken: string): Promise { - const params = { - page: '1', - per_page: '1' - }; - const queryParam = new URLSearchParams(params).toString(); - const response = await this.gistReq('GET', `${this.gitBaseUrl}gists`, gitToken, queryParam); - if (!response.length) { - throw new Error('404'); - } - const mostRecentData = response[0]; - - const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(mostRecentData, gitToken); - return GistApiFileListDto.of(mostRecentData, fileArr); - } - - async getCommitsForAGist(gist_id: string, pageIdx = 1, gitToken: string): Promise { - const page = pageIdx; - const perPage = 5; - const params = { - page: page.toString(), - per_page: perPage.toString() - }; - const queryParam = new URLSearchParams(params).toString(); - const response = await this.gistReq('GET', `${this.gitBaseUrl}gists/${gist_id}/commits`, gitToken, queryParam); - const data = await response.json(); - const commits: CommitDto[] = data.map((history) => CommitDto.of(history)); - return commits; - } - - async getCommit(gist_id: string, commit_id: string, gitToken: string) { - const response = await this.getFilesFromCommit(this.getCommitUrl(gist_id, commit_id), gitToken); - return response; - } - - getCommitUrl(gist_id: string, commit_id: string) { - return `https://api.github.com/gists/${gist_id}/${commit_id}`; - } - - async getFilesFromCommit(commit_url: string, gittoken: string) { - const data = await this.getFileContent(commit_url, gittoken); - const dataJson = JSON.parse(data); - const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(dataJson, gittoken); - return GistApiFileListDto.of(dataJson, fileArr); - } - - async getUserData(gitToken: string): Promise { - const response = await this.gistReq('GET', `${this.gitBaseUrl}user`, gitToken); - const userData = await response.json(); - if (!userData.id || !userData.avatar_url || !userData.login) { - throw new Error('404'); - } - const result: UserDto = UserDto.of(userData); - return result; - } - async getFileContent(raw_url: string, gittoken: string) { - const header = {}; - if (gittoken) { - header['Authorization'] = `Bearer ${gittoken}`; - } - const response = await fetch(raw_url, { - headers: header - }); - if (!response.ok) { - throw new Error('404'); - } - const data = await response.text(); - return data; - } - - async getComments(gitToken: string, gist_id: string): Promise { - const data = await this.gistReq('GET', `${this.gitBaseUrl}gists/${gist_id}/comments`, gitToken); - const comments: CommentDto[] = data.map((comment) => CommentDto.of(comment)); - return comments; - } - - async createComments(gitToken: string, gist_id: string, detail: string): Promise { - const response = await this.gistReq('POST', `${this.gitBaseUrl}gists/${gist_id}/comments`, gitToken, '', detail); - - const data = await response.json(); - const comment: CommentDto = CommentDto.of(data); - return comment; - } - - async updateComment(gitToken: string, gist_id: string, comment_id: string, detail: string): Promise { - const response = await this.gistReq( - 'PATCH', - `${this.gitBaseUrl}gists/${gist_id}/comments/${comment_id}`, - gitToken, - '', - detail - ); - const data = await response.json(); - return true; - } - - async deleteComment(gitToken: string, gist_id: string, comment_id: string): Promise { - const data = await this.gistReq('DELETE', `${this.gitBaseUrl}gists/${gist_id}/comments/${comment_id}`, gitToken); - return true; - } - - async gistReq( - method: string, - commend: string, - gitToken: string = null, - queryParam = '', - body: any = null - ): Promise { - const commendURL = queryParam ? commend + '?' + queryParam : commend; - const requestInit: RequestInit = { - method: method, - headers: GIST_AUTH_HEADER(gitToken) - }; - - if (body) { - requestInit.body = JSON.stringify({ body }); - } - const response = await fetch(commendURL, requestInit); - if (!response.ok) { - throw new HttpException('gist api throw error', HttpStatus.NOT_FOUND); - } - return response; - } - - async parseGistApiFiles(gistData: any, gitToken: string): Promise { - return await Promise.all( - Object.keys(gistData.files).map(async (filename) => { - //trunc 옵션 - const content = await this.getFileContent(gistData.files[filename].raw_url, gitToken); - return GistApiFileDto.of(filename, gistData, content); - }) - ); - } -} +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { CommentDto } from './dto/comment.dto'; +import { CommitDto } from './dto/gist.commit.dto'; +import { GistApiFileDto } from './dto/gistApiFile.dto'; +import { GistApiFileListDto } from './dto/gistApiFileList.dto'; +import { ResponseAllGistsDto } from './dto/response.allGists.dto'; +import { ResponseGistDto } from './dto/response.gist.dto'; +import { UserDto } from './dto/user.dto'; +import { GIST_AUTH_HEADER } from '@/constants/constants'; + +@Injectable() +export class GistService { + gitBaseUrl: string; + constructor() { + this.gitBaseUrl = 'https://api.github.com/'; + } + async getGistList(gitToken: string, page: number, perPage: number): Promise { + let hasNextPage = true; + const currentGistPage = await this.gistPageData(gitToken, page, perPage); + const nextGistPage = await this.gistPageData(gitToken, page + 1, perPage); + + if (nextGistPage.length === 0) { + hasNextPage = false; + } + + const gists = currentGistPage.map((gist) => { + return ResponseGistDto.of(gist); + }); + return ResponseAllGistsDto.of(gists, page, hasNextPage); + } + + async gistPageData(gitToken: string, page: number, per_page: number): Promise { + const params = { + page: page.toString(), + per_page: per_page.toString() + }; + const queryParam = new URLSearchParams(params).toString(); + const gistsData = await this.gistReq('GET', `${this.gitBaseUrl}gists`, gitToken, queryParam); + return await gistsData.json(); + } + + async getGistById(id: string, gitToken: string): Promise { + let response = null; + try { + response = await this.gistReq('GET', `${this.gitBaseUrl}gists/${id}`, gitToken); + } catch (e) { + throw new HttpException('gistId is not exist', HttpStatus.NOT_FOUND); + } + const data = await response.json(); + + const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(data, gitToken); + return GistApiFileListDto.of(data, fileArr); + } + + async getMostRecentGistInUser(gitToken: string): Promise { + const params = { + page: '1', + per_page: '1' + }; + const queryParam = new URLSearchParams(params).toString(); + const response = await this.gistReq('GET', `${this.gitBaseUrl}gists`, gitToken, queryParam); + if (!response.length) { + throw new Error('404'); + } + const mostRecentData = response[0]; + + const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(mostRecentData, gitToken); + return GistApiFileListDto.of(mostRecentData, fileArr); + } + + async getCommitsForAGist(gist_id: string, pageIdx = 1, gitToken: string): Promise { + const page = pageIdx; + const perPage = 5; + const params = { + page: page.toString(), + per_page: perPage.toString() + }; + const queryParam = new URLSearchParams(params).toString(); + const response = await this.gistReq('GET', `${this.gitBaseUrl}gists/${gist_id}/commits`, gitToken, queryParam); + const data = await response.json(); + const commits: CommitDto[] = data.map((history) => CommitDto.of(history)); + return commits; + } + + async getCommit(gist_id: string, commit_id: string, gitToken: string) { + const response = await this.getFilesFromCommit(this.getCommitUrl(gist_id, commit_id), gitToken); + return response; + } + + getCommitUrl(gist_id: string, commit_id: string) { + return `https://api.github.com/gists/${gist_id}/${commit_id}`; + } + + async getFilesFromCommit(commit_url: string, gittoken: string) { + const data = await this.getFileContent(commit_url, gittoken); + const dataJson = JSON.parse(data); + const fileArr: GistApiFileDto[] = await this.parseGistApiFiles(dataJson, gittoken); + return GistApiFileListDto.of(dataJson, fileArr); + } + + async getUserData(gitToken: string): Promise { + const response = await this.gistReq('GET', `${this.gitBaseUrl}user`, gitToken); + const userData = await response.json(); + if (!userData.id || !userData.avatar_url || !userData.login) { + throw new Error('404'); + } + const result: UserDto = UserDto.of(userData); + return result; + } + async getFileContent(raw_url: string, gittoken: string) { + const header = {}; + if (gittoken) { + header['Authorization'] = `Bearer ${gittoken}`; + } + const response = await fetch(raw_url, { + headers: header + }); + if (!response.ok) { + throw new Error('404'); + } + const data = await response.text(); + return data; + } + + async getComments(gitToken: string, gist_id: string): Promise { + const data = await this.gistReq('GET', `${this.gitBaseUrl}gists/${gist_id}/comments`, gitToken); + const comments: CommentDto[] = data.map((comment) => CommentDto.of(comment)); + return comments; + } + + async createComments(gitToken: string, gist_id: string, detail: string): Promise { + const response = await this.gistReq('POST', `${this.gitBaseUrl}gists/${gist_id}/comments`, gitToken, '', detail); + + const data = await response.json(); + const comment: CommentDto = CommentDto.of(data); + return comment; + } + + async updateComment(gitToken: string, gist_id: string, comment_id: string, detail: string): Promise { + const response = await this.gistReq( + 'PATCH', + `${this.gitBaseUrl}gists/${gist_id}/comments/${comment_id}`, + gitToken, + '', + detail + ); + const data = await response.json(); + return true; + } + + async deleteComment(gitToken: string, gist_id: string, comment_id: string): Promise { + const data = await this.gistReq('DELETE', `${this.gitBaseUrl}gists/${gist_id}/comments/${comment_id}`, gitToken); + return true; + } + + async gistReq( + method: string, + commend: string, + gitToken: string = null, + queryParam = '', + body: any = null + ): Promise { + const commendURL = queryParam ? commend + '?' + queryParam : commend; + const requestInit: RequestInit = { + method: method, + headers: GIST_AUTH_HEADER(gitToken) + }; + + if (body) { + requestInit.body = JSON.stringify({ body }); + } + const response = await fetch(commendURL, requestInit); + if (!response.ok) { + throw new HttpException('gist api throw error', HttpStatus.NOT_FOUND); + } + return response; + } + + async parseGistApiFiles(gistData: any, gitToken: string): Promise { + return await Promise.all( + Object.keys(gistData.files).map(async (filename) => { + //trunc 옵션 + const content = await this.getFileContent(gistData.files[filename].raw_url, gitToken); + return GistApiFileDto.of(filename, gistData, content); + }) + ); + } +} diff --git a/apps/backend/src/history/dto/history.responseList.dto.ts b/apps/backend/src/history/dto/history.responseList.dto.ts index c1d5c5bd..01747443 100644 --- a/apps/backend/src/history/dto/history.responseList.dto.ts +++ b/apps/backend/src/history/dto/history.responseList.dto.ts @@ -16,12 +16,12 @@ export class HistoryResponseListDto { max: number; }; - static of(historys: History[], page: number, size: number, total: number) { + static of(historys: History[], page: number, size: number, maxPage: number) { return { list: historys.map((history) => HistoryPublicDto.of(history)), page: { current: page, - max: Math.ceil(total / size) + max: maxPage } }; } diff --git a/apps/backend/src/history/history.controller.ts b/apps/backend/src/history/history.controller.ts index de5df0e7..a7f6d4dc 100644 --- a/apps/backend/src/history/history.controller.ts +++ b/apps/backend/src/history/history.controller.ts @@ -1,80 +1,80 @@ -import { - Body, - Controller, - DefaultValuePipe, - Get, - Headers, - HttpCode, - HttpException, - HttpStatus, - Param, - ParseIntPipe, - Post, - Query, - Req -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { error } from 'console'; -import { Request } from 'express'; -import { HistoryExecRequestDto } from './dto/history.execRequest.dto'; -import { HistoryExecResponseDto } from './dto/history.execResponse.dto'; -import { HistoryGetResponseDto } from './dto/history.getReponse.dto'; -import { HistoryResponseListDto } from './dto/history.responseList.dto'; -import { HistoryService } from './history.service'; -import { AuthService } from '@/auth/auth.service'; -import { HISTORY_STATUS } from '@/constants/constants'; -import { UserService } from '@/user/user.service'; - -@Controller('lotus/:lotusId/history') -export class HistoryController { - constructor(private historyService: HistoryService, private authServer: AuthService) {} - - @Get('error') - async errorQuery() { - return this.historyService.errorQuery(); - } - - @Post() - @HttpCode(200) - @ApiOperation({ summary: '코드 실행 & history 추가' }) - @ApiBody({ type: HistoryExecRequestDto }) - @ApiResponse({ status: 200, description: '실행 성공', type: HistoryExecResponseDto }) - async execCode( - @Req() request: Request, - @Param('lotusId') lotusId: string, - @Body() historyExecRequestDto: HistoryExecRequestDto - ): Promise { - const { input, execFileName } = historyExecRequestDto; - try { - const gitToken = await this.authServer.getUserGitToken(this.authServer.getIdFromRequest(request)); - // const execFileName = 'FunctionDivide.js'; - // const input = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; - return await this.historyService.saveHistory(gitToken, lotusId, execFileName, input); - } catch (e) { - return await this.historyService.saveHistory(null, lotusId, execFileName, input); - } - } - - @Get() - @HttpCode(200) - @ApiOperation({ summary: '해당 lotus의 history 목록 조회' }) - @ApiResponse({ status: 200, description: '실행 성공', type: HistoryResponseListDto }) - @ApiQuery({ name: 'page', type: Number, example: 1 }) - @ApiQuery({ name: 'size', type: Number, example: 5 }) - getHistoryList( - @Param('lotusId') lotusId: string, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number - ): Promise { - return this.historyService.getHistoryList(lotusId, page, size); - } - - @Get(':historyId') - @HttpCode(200) - @ApiOperation({ summary: '해당 historyId의 상세 정보 조회' }) - @ApiResponse({ status: 200, description: '실행 성공', type: HistoryGetResponseDto }) - getHistory(@Param('historyId') historyId: string): Promise { - return this.historyService.getHistoryFromId(historyId); - } -} +import { + Body, + Controller, + DefaultValuePipe, + Get, + Headers, + HttpCode, + HttpException, + HttpStatus, + Param, + ParseIntPipe, + Post, + Query, + Req +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { error } from 'console'; +import { Request } from 'express'; +import { HistoryExecRequestDto } from './dto/history.execRequest.dto'; +import { HistoryExecResponseDto } from './dto/history.execResponse.dto'; +import { HistoryGetResponseDto } from './dto/history.getReponse.dto'; +import { HistoryResponseListDto } from './dto/history.responseList.dto'; +import { HistoryService } from './history.service'; +import { AuthService } from '@/auth/auth.service'; +import { HISTORY_STATUS } from '@/constants/constants'; +import { UserService } from '@/user/user.service'; + +@Controller('lotus/:lotusId/history') +export class HistoryController { + constructor(private historyService: HistoryService, private authServer: AuthService) {} + + @Get('error') + async errorQuery() { + return this.historyService.errorQuery(); + } + + @Post() + @HttpCode(200) + @ApiOperation({ summary: '코드 실행 & history 추가' }) + @ApiBody({ type: HistoryExecRequestDto }) + @ApiResponse({ status: 200, description: '실행 성공', type: HistoryExecResponseDto }) + async execCode( + @Req() request: Request, + @Param('lotusId') lotusId: string, + @Body() historyExecRequestDto: HistoryExecRequestDto + ): Promise { + const { input, execFileName } = historyExecRequestDto; + try { + const gitToken = await this.authServer.getUserGitToken(this.authServer.getIdFromRequest(request)); + // const execFileName = 'FunctionDivide.js'; + // const input = ['1 1 1 1', '1 1 1 1', '1 1 1 1', '1 1 1 1']; + return await this.historyService.saveHistory(gitToken, lotusId, execFileName, input); + } catch (e) { + return await this.historyService.saveHistory(null, lotusId, execFileName, input); + } + } + + @Get() + @HttpCode(200) + @ApiOperation({ summary: '해당 lotus의 history 목록 조회' }) + @ApiResponse({ status: 200, description: '실행 성공', type: HistoryResponseListDto }) + @ApiQuery({ name: 'page', type: Number, example: 1 }) + @ApiQuery({ name: 'size', type: Number, example: 5 }) + getHistoryList( + @Param('lotusId') lotusId: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number + ): Promise { + return this.historyService.getHistoryList(lotusId, page, size); + } + + @Get(':historyId') + @HttpCode(200) + @ApiOperation({ summary: '해당 historyId의 상세 정보 조회' }) + @ApiResponse({ status: 200, description: '실행 성공', type: HistoryGetResponseDto }) + getHistory(@Param('historyId') historyId: string): Promise { + return this.historyService.getHistoryFromId(historyId); + } +} diff --git a/apps/backend/src/history/history.entity.ts b/apps/backend/src/history/history.entity.ts index 77e9002b..b370cd25 100644 --- a/apps/backend/src/history/history.entity.ts +++ b/apps/backend/src/history/history.entity.ts @@ -4,8 +4,7 @@ import { User } from '@/user/user.entity'; @Entity() export class History { - //@PrimaryGeneratedColumn('uuid', { type: 'bigint' }) - @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + @PrimaryGeneratedColumn('uuid', { name: 'history_id' }) historyId: string; @CreateDateColumn({ name: 'created_at' }) @@ -23,7 +22,7 @@ export class History { @Column() status: string; - @ManyToOne(() => Lotus, (lotus) => lotus.historys) + @ManyToOne(() => Lotus, (lotus) => lotus.historys, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'lotus_id' }) // 외래키 이름 설정 lotus: Lotus; } diff --git a/apps/backend/src/history/history.service.ts b/apps/backend/src/history/history.service.ts index 200493d5..a90e825b 100644 --- a/apps/backend/src/history/history.service.ts +++ b/apps/backend/src/history/history.service.ts @@ -1,80 +1,87 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import e from 'express'; -import { HistoryExecResponseDto } from './dto/history.execResponse.dto'; -import { HistoryGetResponseDto } from './dto/history.getReponse.dto'; -import { HistoryResponseListDto } from './dto/history.responseList.dto'; -import { HistoryRepository } from './history.repository'; -import { HISTORY_STATUS, SUPPORTED_LANGUAGES } from '@/constants/constants'; -import { DockerProducer } from '@/docker/docker.producer'; -import { GistApiFileListDto } from '@/gist/dto/gistApiFileList.dto'; -import { GistService } from '@/gist/gist.service'; -import { Lotus } from '@/lotus/lotus.entity'; -import { LotusRepository } from '@/lotus/lotus.repository'; - -@Injectable() -export class HistoryService { - constructor( - private historyRepository: HistoryRepository, - private dockerProducer: DockerProducer, - private lotusRepository: LotusRepository, - private gistService: GistService - ) {} - async saveHistory(gitToken: string, lotusId: string, execFilename: string, inputs: string[]): Promise { - const [lotus]: Lotus[] = await this.lotusRepository.findBy({ lotusId: lotusId }); - const file: GistApiFileListDto = await this.gistService.getCommit(lotus.gistRepositoryId, lotus.commitId, gitToken); - if (!execFilename.endsWith(SUPPORTED_LANGUAGES.JS)) { - throw new HttpException('not supported file extension', HttpStatus.BAD_REQUEST); - } - const history = await this.historyRepository.save({ - input: JSON.stringify(inputs), - execFilename: execFilename, - result: null, - status: 'PENDING', - lotus: lotus - }); - this.execContainer(gitToken, lotus.gistRepositoryId, lotus.commitId, execFilename, inputs, history.historyId); - return HistoryExecResponseDto.of(HISTORY_STATUS.PENDING); - } - - async execContainer( - gitToken: string, - lotusId: string, - commitId: string, - execFilename: string, - inputs: string[], - historyId: string - ) { - try { - const result = await this.dockerProducer.getDocker(gitToken, lotusId, commitId, execFilename, inputs); - await this.historyRepository.update(historyId, { status: HISTORY_STATUS.SUCCESS, result }); - } catch (error) { - await this.historyRepository.update(historyId, { - status: HISTORY_STATUS.ERROR, - result: error.message - }); - } - } - - async getHistoryList(lotusId: string, page: number, size: number): Promise { - const result = await this.historyRepository.findAndCount({ - where: { lotus: { lotusId } }, - skip: (page - 1) * size, - take: size, - order: { createdAt: 'DESC' } - }); - const [historys, total] = result; - - return HistoryResponseListDto.of(historys, page, size, total); - } - async getHistoryFromId(historyId: string): Promise { - const history = await this.historyRepository.findOneBy({ historyId: historyId }); - if (!history) { - throw new HttpException('not exist history', HttpStatus.BAD_REQUEST); - } - return HistoryGetResponseDto.of(history); - } - - async errorQuery() { - return await this.historyRepository.save({ status: HISTORY_STATUS.SUCCESS }); - } -} +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import e from 'express'; +import { HistoryExecResponseDto } from './dto/history.execResponse.dto'; +import { HistoryGetResponseDto } from './dto/history.getReponse.dto'; +import { HistoryResponseListDto } from './dto/history.responseList.dto'; +import { HistoryRepository } from './history.repository'; +import { HISTORY_STATUS, SUPPORTED_LANGUAGES } from '@/constants/constants'; +import { DockerProducer } from '@/docker/docker.producer'; +import { GistApiFileListDto } from '@/gist/dto/gistApiFileList.dto'; +import { GistService } from '@/gist/gist.service'; +import { Lotus } from '@/lotus/lotus.entity'; +import { LotusRepository } from '@/lotus/lotus.repository'; + +@Injectable() +export class HistoryService { + constructor( + private historyRepository: HistoryRepository, + private dockerProducer: DockerProducer, + private lotusRepository: LotusRepository, + private gistService: GistService + ) {} + async saveHistory(gitToken: string, lotusId: string, execFilename: string, inputs: string[]): Promise { + const [lotus]: Lotus[] = await this.lotusRepository.findBy({ lotusId: lotusId }); + const file: GistApiFileListDto = await this.gistService.getCommit(lotus.gistRepositoryId, lotus.commitId, gitToken); + if (!execFilename.endsWith(SUPPORTED_LANGUAGES.JS)) { + throw new HttpException('not supported file extension', HttpStatus.BAD_REQUEST); + } + const history = await this.historyRepository.save({ + input: JSON.stringify(inputs), + execFilename: execFilename, + result: null, + status: 'PENDING', + lotus: lotus + }); + this.execContainer(gitToken, lotus.gistRepositoryId, lotus.commitId, execFilename, inputs, history.historyId); + return HistoryExecResponseDto.of(HISTORY_STATUS.PENDING); + } + + async execContainer( + gitToken: string, + lotusId: string, + commitId: string, + execFilename: string, + inputs: string[], + historyId: string + ) { + try { + const result = await this.dockerProducer.getDocker(gitToken, lotusId, commitId, execFilename, inputs); + await this.historyRepository.update(historyId, { status: HISTORY_STATUS.SUCCESS, result }); + } catch (error) { + console.error(`HTTP ${404} Error: ${error.message} | Path: lotus/${lotusId}/history/${historyId}`); + await this.historyRepository.update(historyId, { + status: HISTORY_STATUS.ERROR, + result: error.message + }); + } + } + + async getHistoryList(lotusId: string, page: number, size: number): Promise { + const result = await this.historyRepository.findAndCount({ + where: { lotus: { lotusId } }, + skip: (page - 1) * size, + take: size, + order: { createdAt: 'DESC' } + }); + const [historys, total] = result; + const maxPage = Math.ceil(total / size); + if (page > maxPage && maxPage !== 0) { + throw new HttpException('page must be lower than max page', HttpStatus.NOT_FOUND); + } + if (page <= 0) { + throw new HttpException('page must be higher than 0', HttpStatus.NOT_FOUND); + } + return HistoryResponseListDto.of(historys, page, size, maxPage); + } + async getHistoryFromId(historyId: string): Promise { + const history = await this.historyRepository.findOneBy({ historyId: historyId }); + if (!history) { + throw new HttpException('not exist history', HttpStatus.BAD_REQUEST); + } + return HistoryGetResponseDto.of(history); + } + + async errorQuery() { + return await this.historyRepository.save({ status: HISTORY_STATUS.SUCCESS }); + } +} diff --git a/apps/backend/src/lotus/dto/lotus.detail.dto.ts b/apps/backend/src/lotus/dto/lotus.detail.dto.ts index 8696e922..bfbeebef 100644 --- a/apps/backend/src/lotus/dto/lotus.detail.dto.ts +++ b/apps/backend/src/lotus/dto/lotus.detail.dto.ts @@ -74,7 +74,7 @@ export class LotusDetailDto { SimpleFileResponseDto.ofFileApiDto(file) ); const simpleUser: SimpleUserResponseDto = SimpleUserResponseDto.ofUserDto(lotus.user); - const simpleTags: string[] = lotus.tags.map((tag) => tag.tagName); + const simpleTags: string[] = lotus.tags.map((tag) => tag.tag.tagName); return { id: lotus.lotusId, title: lotus.title, diff --git a/apps/backend/src/lotus/dto/lotus.dto.ts b/apps/backend/src/lotus/dto/lotus.dto.ts index 169dfb6d..e0216218 100644 --- a/apps/backend/src/lotus/dto/lotus.dto.ts +++ b/apps/backend/src/lotus/dto/lotus.dto.ts @@ -3,7 +3,7 @@ import { IsBoolean, IsString, ValidateNested } from 'class-validator'; import { LotusCreateRequestDto } from './lotus.createRequest.dto'; import { Comment } from '@/comment/comment.entity'; import { History } from '@/history/history.entity'; -import { Tag } from '@/tag/tag.entity'; +import { LotusTag } from '@/relation/lotus.tag.entity'; import { User } from '@/user/user.entity'; export class LotusDto { @@ -44,10 +44,10 @@ export class LotusDto { historys: History[]; @ValidateNested({ each: true }) - @Type(() => Tag) - tags: Tag[]; + @Type(() => LotusTag) + tags: LotusTag[]; - constructor(commitId: string, user: User, lotusInputData: LotusCreateRequestDto, tags: Tag[]) { + constructor(commitId: string, user: User, lotusInputData: LotusCreateRequestDto) { this.title = lotusInputData.title; this.isPublic = lotusInputData.isPublic; this.gistRepositoryId = lotusInputData.gistUuid; @@ -56,7 +56,6 @@ export class LotusDto { this.version = lotusInputData.version; this.comments = []; this.historys = []; - this.tags = tags; this.user = user; } } diff --git a/apps/backend/src/lotus/dto/lotus.response.dto.ts b/apps/backend/src/lotus/dto/lotus.response.dto.ts index 4676961d..da2767bd 100644 --- a/apps/backend/src/lotus/dto/lotus.response.dto.ts +++ b/apps/backend/src/lotus/dto/lotus.response.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsDate, IsString, IsUrl, ValidateNested } from 'class-validator'; import { SimpleUserResponseDto } from './simple.user.response.dto'; import { Lotus } from '@/lotus/lotus.entity'; +import { Tag } from '@/tag/tag.entity'; export class LotusResponseDto { @IsString() @@ -53,8 +54,7 @@ export class LotusResponseDto { }) tags: string[]; - static ofSpreadData(user: SimpleUserResponseDto, lotus: Lotus): LotusResponseDto { - const tags = lotus.tags.map((tag) => tag.tagName); + static ofSpreadData(user: SimpleUserResponseDto, lotus: Lotus, tags: string[]): LotusResponseDto { return { id: lotus.lotusId, author: user, @@ -69,7 +69,7 @@ export class LotusResponseDto { static ofLotus(lotus: Lotus): LotusResponseDto { const simpleUser = SimpleUserResponseDto.ofUserDto(lotus.user); - const tags = lotus.tags.map((tag) => tag.tagName); + const tags = lotus.tags.map((tag) => tag.tag.tagName); return { id: lotus.lotusId, author: simpleUser, diff --git a/apps/backend/src/lotus/lotus.controller.ts b/apps/backend/src/lotus/lotus.controller.ts index 065833a4..6ea39dde 100644 --- a/apps/backend/src/lotus/lotus.controller.ts +++ b/apps/backend/src/lotus/lotus.controller.ts @@ -1,98 +1,98 @@ -import { - Body, - Controller, - DefaultValuePipe, - Delete, - Get, - HttpCode, - Param, - ParseIntPipe, - Patch, - Post, - Query, - Req -} from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { Request } from 'express'; -import { LotusCreateRequestDto } from './dto/lotus.createRequest.dto'; -import { LotusDetailDto } from './dto/lotus.detail.dto'; -import { LotusPublicDto } from './dto/lotus.public.dto'; -import { LotusResponseDto } from './dto/lotus.response.dto'; -import { LotusUpdateRequestDto } from './dto/lotus.updateRequest.dto'; -import { MessageDto } from './dto/message.dto'; -import { LotusService } from './lotus.service'; -import { AuthService } from '@/auth/auth.service'; - -@Controller('lotus') -export class LotusController { - constructor(private readonly lotusService: LotusService, private authService: AuthService) {} - - @Post() - @HttpCode(201) - @ApiOperation({ summary: 'lotus 생성 및 추가' }) - @ApiBody({ type: LotusCreateRequestDto }) - @ApiResponse({ status: 201, description: '실행 성공', type: LotusResponseDto }) - async createLotus( - @Req() request: Request, - @Body() lotusCreateRequestDto: LotusCreateRequestDto - ): Promise { - const userId = this.authService.getIdFromRequest(request); - const gitToken = await this.authService.getUserGitToken(userId); - return await this.lotusService.createLotus(userId, gitToken, lotusCreateRequestDto); - } - - @Patch('/:lotusId') - @HttpCode(200) - @ApiOperation({ summary: 'lotus 업데이트' }) - @ApiBody({ type: LotusCreateRequestDto }) - @ApiResponse({ status: 200, description: '실행 성공', type: LotusResponseDto }) - @ApiQuery({ name: 'lotusId', type: String, example: '25' }) - updateLotus( - @Req() request: Request, - @Param('lotusId') lotusId: string, - @Body() lotusUpdateRequestDto: LotusUpdateRequestDto - ): Promise { - const userId = this.authService.getIdFromRequest(request); - return this.lotusService.updateLotus(lotusId, lotusUpdateRequestDto, userId); - } - - @Delete('/:lotusId') - @HttpCode(204) - @ApiOperation({ summary: 'lotus 삭제' }) - @ApiResponse({ status: 204, description: '실행 성공', type: MessageDto }) - @ApiQuery({ name: 'lotusId', type: String, example: '25' }) - deleteLotus(@Req() request: Request, @Param('lotusId') lotusId: string): Promise { - const userId = this.authService.getIdFromRequest(request); - return this.lotusService.deleteLotus(lotusId, userId); - } - - @Get() - @HttpCode(200) - @ApiOperation({ summary: 'lotus public 목록 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: LotusPublicDto }) - @ApiQuery({ name: 'page', type: String, example: '1', required: false }) - @ApiQuery({ name: 'size', type: String, example: '10', required: false }) - @ApiQuery({ name: 'search', type: String, example: 'Web', required: false }) - getPublicLotus( - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number, - @Query('search', new DefaultValuePipe('')) search: string - ): Promise { - return this.lotusService.getPublicLotus(page, size, search); - } - - @Get('/:lotusId') - @HttpCode(200) - @ApiOperation({ summary: 'lotus 상세 데이터 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: LotusDetailDto }) - @ApiQuery({ name: 'lotusId', type: String, example: '25' }) - async getLotusDetail(@Req() request: Request, @Param('lotusId') lotusId: string): Promise { - try { - const userId = this.authService.getIdFromRequest(request); - const gitToken = await this.authService.getUserGitToken(userId); - return this.lotusService.getLotusFile(userId, gitToken, lotusId); - } catch (e) { - return this.lotusService.getLotusFile(null, null, lotusId); - } - } -} +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + HttpCode, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Req +} from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { Request } from 'express'; +import { LotusCreateRequestDto } from './dto/lotus.createRequest.dto'; +import { LotusDetailDto } from './dto/lotus.detail.dto'; +import { LotusPublicDto } from './dto/lotus.public.dto'; +import { LotusResponseDto } from './dto/lotus.response.dto'; +import { LotusUpdateRequestDto } from './dto/lotus.updateRequest.dto'; +import { MessageDto } from './dto/message.dto'; +import { LotusService } from './lotus.service'; +import { AuthService } from '@/auth/auth.service'; + +@Controller('lotus') +export class LotusController { + constructor(private readonly lotusService: LotusService, private authService: AuthService) {} + + @Post() + @HttpCode(201) + @ApiOperation({ summary: 'lotus 생성 및 추가' }) + @ApiBody({ type: LotusCreateRequestDto }) + @ApiResponse({ status: 201, description: '실행 성공', type: LotusResponseDto }) + async createLotus( + @Req() request: Request, + @Body() lotusCreateRequestDto: LotusCreateRequestDto + ): Promise { + const userId = this.authService.getIdFromRequest(request); + const gitToken = await this.authService.getUserGitToken(userId); + return await this.lotusService.createLotus(userId, gitToken, lotusCreateRequestDto); + } + + @Patch('/:lotusId') + @HttpCode(200) + @ApiOperation({ summary: 'lotus 업데이트' }) + @ApiBody({ type: LotusCreateRequestDto }) + @ApiResponse({ status: 200, description: '실행 성공', type: LotusResponseDto }) + @ApiQuery({ name: 'lotusId', type: String, example: '25' }) + updateLotus( + @Req() request: Request, + @Param('lotusId') lotusId: string, + @Body() lotusUpdateRequestDto: LotusUpdateRequestDto + ): Promise { + const userId = this.authService.getIdFromRequest(request); + return this.lotusService.updateLotus(lotusId, lotusUpdateRequestDto, userId); + } + + @Delete('/:lotusId') + @HttpCode(204) + @ApiOperation({ summary: 'lotus 삭제' }) + @ApiResponse({ status: 204, description: '실행 성공', type: MessageDto }) + @ApiQuery({ name: 'lotusId', type: String, example: '25' }) + deleteLotus(@Req() request: Request, @Param('lotusId') lotusId: string): Promise { + const userId = this.authService.getIdFromRequest(request); + return this.lotusService.deleteLotus(lotusId, userId); + } + + @Get() + @HttpCode(200) + @ApiOperation({ summary: 'lotus public 목록 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: LotusPublicDto }) + @ApiQuery({ name: 'page', type: String, example: '1', required: false }) + @ApiQuery({ name: 'size', type: String, example: '10', required: false }) + @ApiQuery({ name: 'search', type: String, example: 'Web', required: false }) + getPublicLotus( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number, + @Query('search', new DefaultValuePipe('')) search: string + ): Promise { + return this.lotusService.getPublicLotus(page, size, search); + } + + @Get('/:lotusId') + @HttpCode(200) + @ApiOperation({ summary: 'lotus 상세 데이터 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: LotusDetailDto }) + @ApiQuery({ name: 'lotusId', type: String, example: '25' }) + async getLotusDetail(@Req() request: Request, @Param('lotusId') lotusId: string): Promise { + try { + const userId = this.authService.getIdFromRequest(request); + const gitToken = await this.authService.getUserGitToken(userId); + return this.lotusService.getLotusFile(userId, gitToken, lotusId); + } catch (e) { + return this.lotusService.getLotusFile(null, null, lotusId); + } + } +} diff --git a/apps/backend/src/lotus/lotus.entity.ts b/apps/backend/src/lotus/lotus.entity.ts index b486d15d..d492a79e 100644 --- a/apps/backend/src/lotus/lotus.entity.ts +++ b/apps/backend/src/lotus/lotus.entity.ts @@ -11,13 +11,12 @@ import { } from 'typeorm'; import { Comment } from '@/comment/comment.entity'; import { History } from '@/history/history.entity'; -import { Tag } from '@/tag/tag.entity'; +import { LotusTag } from '@/relation/lotus.tag.entity'; import { User } from '@/user/user.entity'; @Entity() export class Lotus { - //@PrimaryGeneratedColumn('uuid', { type: 'bigint' }) - @PrimaryGeneratedColumn('increment', { type: 'bigint', name: 'lotus_id' }) + @PrimaryGeneratedColumn('uuid', { name: 'lotus_id' }) lotusId: string; @Column() @@ -41,7 +40,7 @@ export class Lotus { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; - @ManyToOne(() => User, (user) => user.lotuses) + @ManyToOne(() => User, (user) => user.lotuses, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) // 외래키 이름 설정 user: User; @@ -51,17 +50,6 @@ export class Lotus { @OneToMany(() => History, (history) => history.lotus, { cascade: ['remove'] }) historys: History[]; - @ManyToMany(() => Tag, { cascade: ['insert', 'update', 'remove'] }) - @JoinTable({ - name: 'lotus_tags', // 교차 테이블 이름 지정 - joinColumn: { - name: 'lotus_id', // 이 컬럼은 Lotus 엔티티를 참조 - referencedColumnName: 'lotusId' - }, - inverseJoinColumn: { - name: 'tag_id', // 이 컬럼은 Tag 엔티티를 참조 - referencedColumnName: 'tagId' - } - }) - tags: Tag[]; + @OneToMany(() => LotusTag, (lotusTag) => lotusTag.lotus, { cascade: ['remove'] }) + tags: LotusTag[]; } diff --git a/apps/backend/src/lotus/lotus.module.ts b/apps/backend/src/lotus/lotus.module.ts index 393baf53..7555cd0f 100644 --- a/apps/backend/src/lotus/lotus.module.ts +++ b/apps/backend/src/lotus/lotus.module.ts @@ -6,6 +6,7 @@ import { LotusRepository } from './lotus.repository'; import { LotusService } from './lotus.service'; import { AuthModule } from '@/auth/auth.module'; import { GistModule } from '@/gist/gist.module'; +import { LotusTagModule } from '@/relation/lotus.tag.module'; import { TagModule } from '@/tag/tag.module'; import { UserModule } from '@/user/user.module'; @@ -15,7 +16,7 @@ import { UserModule } from '@/user/user.module'; GistModule, forwardRef(() => UserModule), forwardRef(() => AuthModule), - TagModule + LotusTagModule ], controllers: [LotusController], providers: [LotusService, LotusRepository], diff --git a/apps/backend/src/lotus/lotus.service.ts b/apps/backend/src/lotus/lotus.service.ts index 224df691..7f716e82 100644 --- a/apps/backend/src/lotus/lotus.service.ts +++ b/apps/backend/src/lotus/lotus.service.ts @@ -1,210 +1,213 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { title } from 'process'; -import { In, Like } from 'typeorm'; -import { LotusCreateRequestDto } from './dto/lotus.createRequest.dto'; -import { LotusDetailDto } from './dto/lotus.detail.dto'; -import { LotusDto } from './dto/lotus.dto'; -import { LotusPublicDto } from './dto/lotus.public.dto'; -import { LotusResponseDto } from './dto/lotus.response.dto'; -import { LotusUpdateRequestDto } from './dto/lotus.updateRequest.dto'; -import { MessageDto } from './dto/message.dto'; -import { SimpleUserResponseDto } from './dto/simple.user.response.dto'; -import { Lotus } from './lotus.entity'; -import { LotusRepository } from './lotus.repository'; -import { GistService } from '@/gist/gist.service'; -import { Tag } from '@/tag/tag.entity'; -import { TagService } from '@/tag/tag.service'; -import { UserService } from '@/user/user.service'; - -@Injectable() -export class LotusService { - constructor( - private readonly lotusRepository: LotusRepository, - private readonly gistService: GistService, - private readonly userService: UserService, - private readonly tagService: TagService - ) {} - async createLotus( - userId: string, - gitToken: string, - lotusInputData: LotusCreateRequestDto - ): Promise { - if (!lotusInputData.title) { - throw new HttpException("title can't use empty", HttpStatus.BAD_REQUEST); - } - if (!lotusInputData.language) { - lotusInputData.language = 'JavaScript'; - } - if (!lotusInputData.version) { - lotusInputData.version = 'NodeJs:v22.11.0'; - } - const commits = await this.gistService.getCommitsForAGist(lotusInputData.gistUuid, 1, gitToken); - if (!commits || commits.length < 1) { - throw new HttpException('gistId is not exist', HttpStatus.NOT_FOUND); - } - const currentCommitId = commits[0].commitId; - - if (await this.checkAlreadyExist(lotusInputData.gistUuid, currentCommitId)) { - throw new HttpException('same commit Lotus already exist.', HttpStatus.CONFLICT); - } - const userData = await this.userService.findOneByUserId(userId); - const tags: Tag[] = await Promise.all( - lotusInputData.tags.map((tag) => { - return this.tagService.getTag(tag); - }) - ); - await this.saveLotus(new LotusDto(currentCommitId, userData, lotusInputData, tags)); - const lotusData = await this.lotusRepository.findOne({ - where: { gistRepositoryId: lotusInputData.gistUuid, commitId: currentCommitId }, - relations: ['tags'] - }); - - return LotusResponseDto.ofSpreadData(SimpleUserResponseDto.ofUserDto(userData), lotusData); - } - - async updateLotus( - lotusId: string, - lotusUpdateRequestDto: LotusUpdateRequestDto, - userIdWhoWantToUpdate: string - ): Promise { - const foundUser = await this.userService.findOneByUserId(userIdWhoWantToUpdate); - if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); - const updateLotus = await this.lotusRepository.findOne({ - where: { lotusId }, - relations: ['user', 'tags'] - }); - if (!updateLotus) throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); - if (updateLotus.user.userId !== userIdWhoWantToUpdate) { - throw new HttpException("can't modify this lotus", HttpStatus.FORBIDDEN); - } - - if (lotusUpdateRequestDto.isPublic !== undefined) { - updateLotus.isPublic = lotusUpdateRequestDto.isPublic; - } else { - if (!lotusUpdateRequestDto.title) { - throw new HttpException("title can't use empty", HttpStatus.BAD_REQUEST); - } else { - updateLotus.title = lotusUpdateRequestDto.title; - } - if (lotusUpdateRequestDto.tags) { - const tags = await Promise.all(lotusUpdateRequestDto.tags.map((tag) => this.tagService.getTag(tag))); - updateLotus.tags = tags; - } - } - const result = await this.lotusRepository.save(updateLotus); - if (!result) throw new HttpException('update fail', HttpStatus.BAD_REQUEST); - return LotusResponseDto.ofSpreadData(SimpleUserResponseDto.ofUserDto(updateLotus.user), updateLotus); - } - - async deleteLotus(lotusId: string, userIdWhoWantToDelete: string): Promise { - const foundUser = await this.userService.findOneByUserId(userIdWhoWantToDelete); - if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); - const deleteLotus = await this.lotusRepository.findOne({ - where: { lotusId }, - relations: ['user'] - }); - if (!deleteLotus) throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); - if (deleteLotus.user.userId !== userIdWhoWantToDelete) { - throw new HttpException("can't remove this lotus", HttpStatus.FORBIDDEN); - } - - const result = await this.lotusRepository.delete({ lotusId }); - if (!result.affected) throw new HttpException('delete fail', HttpStatus.NOT_FOUND); - - return new MessageDto('ok'); - } - - async getLotusFile(userId: string, gitToken: string, lotusId: string): Promise { - const lotusData = await this.lotusRepository.findOne({ - where: { lotusId }, - relations: ['tags', 'user'] - }); - if (!lotusData) { - throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); - } - if (!lotusData.isPublic && lotusData.user.userId !== userId) { - throw new HttpException("can't view this lotus", HttpStatus.FORBIDDEN); - } - - const commitFiles = await this.gistService.getCommit(lotusData.gistRepositoryId, lotusData.commitId, gitToken); - - return LotusDetailDto.ofGistFileListDto(commitFiles, lotusData); - } - - async getPublicLotus(page: number, size: number, search: string): Promise { - //const [lotusData, totalNum] = await this.getLotusByTags(page, size, search); - const [lotusData, totalNum] = await this.getLotusByTitle(page, size, search); - const maxPage = Math.ceil(totalNum / size); - if (page > maxPage && maxPage !== 0) { - throw new HttpException('page must be lower than max page', HttpStatus.NOT_FOUND); - } - if (page <= 0) { - throw new HttpException('page must be higher than 0', HttpStatus.NOT_FOUND); - } - return LotusPublicDto.ofLotusList(lotusData, page, maxPage); - } - - async getLotusByTitle(page: number, size: number, search: string) { - const whereData = { - isPublic: true - }; - if (search) { - whereData['title'] = Like(`%${search}%`); - } - return await this.lotusRepository.findAndCount({ - where: whereData, - skip: (page - 1) * size, - take: size, - relations: ['tags', 'user'], - order: { createdAt: 'DESC' } - }); - } - - async getLotusByTags(page: number, size: number, search: string) { - const whereData = { - isPublic: true - }; - if (search) { - const tags = await this.tagService.searchTag(search); - whereData['tags'] = { tagId: In(tags) }; - } - return await this.lotusRepository.findAndCount({ - where: whereData, - skip: (page - 1) * size, - take: size, - relations: ['tags', 'user'], - order: { createdAt: 'DESC' } - }); - } - - async getUserLotus(userId: string, page: number, size: number) { - const user = this.userService.findOneByUserId(userId); - if (!user) { - throw new HttpException('user data is not found', HttpStatus.UNAUTHORIZED); - } - - const [lotusData, totalNum] = await this.lotusRepository.findAndCount({ - where: { user: { userId } }, - skip: (page - 1) * size, - take: size, - relations: ['tags', 'user'], - order: { createdAt: 'DESC' } - }); - const maxPage = Math.ceil(totalNum / size); - if (page > maxPage && maxPage !== 0) { - throw new HttpException('page must be lower than max page', HttpStatus.NOT_FOUND); - } - if (page <= 0) { - throw new HttpException('page must be higher than 0', HttpStatus.NOT_FOUND); - } - return LotusPublicDto.ofLotusList(lotusData, page, maxPage); - } - - async checkAlreadyExist(gistUuid: string, commitId: string) { - return await this.lotusRepository.exists({ where: { gistRepositoryId: gistUuid, commitId: commitId } }); - } - - async saveLotus(lotus: Lotus): Promise { - await this.lotusRepository.save(lotus); - } -} +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { title } from 'process'; +import { In, Like } from 'typeorm'; +import { LotusCreateRequestDto } from './dto/lotus.createRequest.dto'; +import { LotusDetailDto } from './dto/lotus.detail.dto'; +import { LotusDto } from './dto/lotus.dto'; +import { LotusPublicDto } from './dto/lotus.public.dto'; +import { LotusResponseDto } from './dto/lotus.response.dto'; +import { LotusUpdateRequestDto } from './dto/lotus.updateRequest.dto'; +import { MessageDto } from './dto/message.dto'; +import { SimpleUserResponseDto } from './dto/simple.user.response.dto'; +import { Lotus } from './lotus.entity'; +import { LotusRepository } from './lotus.repository'; +import { GistService } from '@/gist/gist.service'; +import { LotusTagService } from '@/relation/lotus.tag.service'; +import { UserService } from '@/user/user.service'; + +@Injectable() +export class LotusService { + constructor( + private readonly lotusRepository: LotusRepository, + private readonly gistService: GistService, + private readonly userService: UserService, + private readonly lotusTagService: LotusTagService + ) {} + async createLotus( + userId: string, + gitToken: string, + lotusInputData: LotusCreateRequestDto + ): Promise { + if (!lotusInputData.title) { + throw new HttpException("title can't use empty", HttpStatus.BAD_REQUEST); + } + if (!lotusInputData.language) { + lotusInputData.language = 'JavaScript'; + } + if (!lotusInputData.version) { + lotusInputData.version = 'NodeJs:v22.11.0'; + } + const commits = await this.gistService.getCommitsForAGist(lotusInputData.gistUuid, 1, gitToken); + if (!commits || commits.length < 1) { + throw new HttpException('gistId is not exist', HttpStatus.NOT_FOUND); + } + const currentCommitId = commits[0].commitId; + + if (await this.checkAlreadyExist(lotusInputData.gistUuid, currentCommitId)) { + throw new HttpException('same commit Lotus already exist.', HttpStatus.CONFLICT); + } + const userData = await this.userService.findOneByUserId(userId); + await this.saveLotus(new LotusDto(currentCommitId, userData, lotusInputData)); + const lotusData = await this.lotusRepository.findOne({ + where: { gistRepositoryId: lotusInputData.gistUuid, commitId: currentCommitId } + }); + await Promise.all( + lotusInputData.tags.map((tag) => { + return this.lotusTagService.getLotusTagRelation(lotusData, tag); + }) + ); + + return LotusResponseDto.ofSpreadData(SimpleUserResponseDto.ofUserDto(userData), lotusData, lotusInputData.tags); + } + + async updateLotus( + lotusId: string, + lotusUpdateRequestDto: LotusUpdateRequestDto, + userIdWhoWantToUpdate: string + ): Promise { + const foundUser = await this.userService.findOneByUserId(userIdWhoWantToUpdate); + if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); + const targetLotus = await this.lotusRepository.findOne({ + where: { lotusId }, + relations: ['user', 'tags', 'tags.tag'] + }); + if (!targetLotus) throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); + if (targetLotus.user.userId !== userIdWhoWantToUpdate) { + throw new HttpException("can't modify this lotus", HttpStatus.FORBIDDEN); + } + const updateContent = {}; + if (lotusUpdateRequestDto.isPublic !== undefined) { + updateContent['isPublic'] = lotusUpdateRequestDto.isPublic; + } else { + if (!lotusUpdateRequestDto.title) { + throw new HttpException("title can't use empty", HttpStatus.BAD_REQUEST); + } else { + updateContent['title'] = lotusUpdateRequestDto.title; + } + if (lotusUpdateRequestDto.tags !== undefined) { + await this.lotusTagService.updateRelation(targetLotus, lotusUpdateRequestDto.tags); + } + } + const result = await this.lotusRepository.update({ lotusId: targetLotus.lotusId }, updateContent); + if (!result) throw new HttpException('update fail', HttpStatus.BAD_REQUEST); + return LotusResponseDto.ofSpreadData( + SimpleUserResponseDto.ofUserDto(targetLotus.user), + targetLotus, + lotusUpdateRequestDto.tags !== undefined + ? lotusUpdateRequestDto.tags + : targetLotus.tags.map((tag) => tag.tag.tagName) + ); + } + + async deleteLotus(lotusId: string, userIdWhoWantToDelete: string): Promise { + const foundUser = await this.userService.findOneByUserId(userIdWhoWantToDelete); + if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); + const deleteLotus = await this.lotusRepository.findOne({ + where: { lotusId }, + relations: ['user'] + }); + if (!deleteLotus) throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); + if (deleteLotus.user.userId !== userIdWhoWantToDelete) { + throw new HttpException("can't remove this lotus", HttpStatus.FORBIDDEN); + } + + const result = await this.lotusRepository.delete({ lotusId }); + if (!result.affected) throw new HttpException('delete fail', HttpStatus.NOT_FOUND); + + return new MessageDto('ok'); + } + + async getLotusFile(userId: string, gitToken: string, lotusId: string): Promise { + const lotusData = await this.lotusRepository.findOne({ + where: { lotusId }, + relations: ['tags', 'tags.tag', 'user'] + }); + if (!lotusData) { + throw new HttpException('lotusId is not exist', HttpStatus.NOT_FOUND); + } + if (!lotusData.isPublic && lotusData.user.userId !== userId) { + throw new HttpException("can't view this lotus", HttpStatus.FORBIDDEN); + } + + const commitFiles = await this.gistService.getCommit(lotusData.gistRepositoryId, lotusData.commitId, gitToken); + + return LotusDetailDto.ofGistFileListDto(commitFiles, lotusData); + } + + async getPublicLotus(page: number, size: number, search: string): Promise { + //const [lotusData, totalNum] = await this.getLotusByTags(page, size, search); + const [lotusData, totalNum] = await this.getLotusByTitle(page, size, search); + const maxPage = Math.ceil(totalNum / size); + if (page > maxPage && maxPage !== 0) { + throw new HttpException('page must be lower than max page', HttpStatus.NOT_FOUND); + } + if (page <= 0) { + throw new HttpException('page must be higher than 0', HttpStatus.NOT_FOUND); + } + return LotusPublicDto.ofLotusList(lotusData, page, maxPage); + } + + async getLotusByTitle(page: number, size: number, search: string) { + const whereData = { + isPublic: true + }; + if (search) { + whereData['title'] = Like(`%${search}%`); + } + return await this.lotusRepository.findAndCount({ + where: whereData, + skip: (page - 1) * size, + take: size, + relations: ['tags', 'tags.tag', 'user'], + order: { createdAt: 'DESC' } + }); + } + + async getLotusByTags(page: number, size: number, search: string) { + const whereData = { + isPublic: true + }; + if (search) { + const lotuses = await this.lotusTagService.searchTag(search); + whereData['lotusId'] = In(lotuses); + } + return await this.lotusRepository.findAndCount({ + where: whereData, + skip: (page - 1) * size, + take: size, + relations: ['tags', 'tags.tag', 'user'], + order: { createdAt: 'DESC' } + }); + } + + async getUserLotus(userId: string, page: number, size: number) { + const user = this.userService.findOneByUserId(userId); + if (!user) { + throw new HttpException('user data is not found', HttpStatus.UNAUTHORIZED); + } + + const [lotusData, totalNum] = await this.lotusRepository.findAndCount({ + where: { user: { userId } }, + skip: (page - 1) * size, + take: size, + relations: ['tags', 'tags.tag', 'user'], + order: { createdAt: 'DESC' } + }); + const maxPage = Math.ceil(totalNum / size); + if (page > maxPage && maxPage !== 0) { + throw new HttpException('page must be lower than max page', HttpStatus.NOT_FOUND); + } + if (page <= 0) { + throw new HttpException('page must be higher than 0', HttpStatus.NOT_FOUND); + } + return LotusPublicDto.ofLotusList(lotusData, page, maxPage); + } + + async checkAlreadyExist(gistUuid: string, commitId: string) { + return await this.lotusRepository.exists({ where: { gistRepositoryId: gistUuid, commitId: commitId } }); + } + + async saveLotus(lotus: Lotus): Promise { + await this.lotusRepository.save(lotus); + } +} diff --git a/apps/backend/src/relation/lotus.tag.entity.ts b/apps/backend/src/relation/lotus.tag.entity.ts new file mode 100644 index 00000000..ac2f81ee --- /dev/null +++ b/apps/backend/src/relation/lotus.tag.entity.ts @@ -0,0 +1,17 @@ +import { Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Lotus } from '@/lotus/lotus.entity'; +import { Tag } from '@/tag/tag.entity'; + +@Entity() +export class LotusTag { + @PrimaryGeneratedColumn('increment', { type: 'bigint', name: 'lotus_tag_id' }) + lotusTagId: string; + + @ManyToOne(() => Lotus, (lotus) => lotus.tags, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'lotus_id' }) + lotus: Lotus; + + @ManyToOne(() => Tag, (tag) => tag.lotuses, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tag_id' }) + tag: Tag; +} diff --git a/apps/backend/src/relation/lotus.tag.module.ts b/apps/backend/src/relation/lotus.tag.module.ts new file mode 100644 index 00000000..419b5304 --- /dev/null +++ b/apps/backend/src/relation/lotus.tag.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LotusTag } from './lotus.tag.entity'; +import { LotusTagRepository } from './lotus.tag.repository'; +import { LotusTagService } from './lotus.tag.service'; +import { TagModule } from '@/tag/tag.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([LotusTag]), TagModule], + providers: [LotusTagService, LotusTagRepository], + exports: [LotusTagService] +}) +export class LotusTagModule {} diff --git a/apps/backend/src/relation/lotus.tag.repository.ts b/apps/backend/src/relation/lotus.tag.repository.ts new file mode 100644 index 00000000..4a0f8510 --- /dev/null +++ b/apps/backend/src/relation/lotus.tag.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Like, Repository } from 'typeorm'; +import { LotusTag } from './lotus.tag.entity'; + +@Injectable() +export class LotusTagRepository extends Repository { + constructor(private dataSource: DataSource) { + super(LotusTag, dataSource.createEntityManager()); + } +} diff --git a/apps/backend/src/relation/lotus.tag.service.ts b/apps/backend/src/relation/lotus.tag.service.ts new file mode 100644 index 00000000..3756df7e --- /dev/null +++ b/apps/backend/src/relation/lotus.tag.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; +import { LotusTagRepository } from './lotus.tag.repository'; +import { Lotus } from '@/lotus/lotus.entity'; +import { Tag } from '@/tag/tag.entity'; +import { TagService } from '@/tag/tag.service'; + +@Injectable() +export class LotusTagService { + constructor(private readonly lotusTagRepository: LotusTagRepository, private readonly tagService: TagService) {} + + async createLotusTagRelation(lotus: Lotus, tag: Tag) { + await this.lotusTagRepository.save({ lotus, tag }); + return await this.lotusTagRepository.findOne({ + where: { lotus: { lotusId: lotus.lotusId }, tag: { tagId: tag.tagId } }, + relations: ['lotus', 'tag'] + }); + } + + async getLotusTagRelation(lotus: Lotus, tagName: string) { + const tag = await this.tagService.getTag(tagName); + let relation = await this.lotusTagRepository.findOne({ + where: { lotus: { lotusId: lotus.lotusId }, tag: { tagId: tag.tagId } }, + relations: ['lotus', 'tag'] + }); + if (!relation) { + relation = await this.createLotusTagRelation(lotus, tag); + } + return relation; + } + + async searchTag(search: string) { + const tags = await this.tagService.searchTag(search); + const lotusTags = await this.lotusTagRepository.find({ + where: { tag: In(tags) }, + relations: ['lotus'] + }); + return lotusTags.map((relation) => relation.lotus.lotusId); + } + + async updateRelation(lotus: Lotus, tagNames: string[]) { + const data = await Promise.all( + tagNames.map(async (tag) => { + return await this.getLotusTagRelation(lotus, tag); + }) + ); + const tagIds = data.map((tag) => tag.tag.tagId); + await this.lotusTagRepository.delete({ + lotus: { lotusId: lotus.lotusId }, + tag: { tagId: Not(In(tagIds)) } + }); + + await this.tagService.deleteNoRelationTags(await this.findUsingTags()); + } + + async findUsingTags() { + const allData = await this.lotusTagRepository.find({ relations: ['tag'] }); + return allData.map((data) => data.tag.tagId); + } +} diff --git a/apps/backend/src/tag/tag.controller.ts b/apps/backend/src/tag/tag.controller.ts index 7dd63af3..a31c4317 100644 --- a/apps/backend/src/tag/tag.controller.ts +++ b/apps/backend/src/tag/tag.controller.ts @@ -13,6 +13,6 @@ export class TagController { @Get() searchTag(@Query('keyword') keyword: string) { - return this.tagService.searchTag(keyword); + return this.tagService.searchTagNames(keyword); } } diff --git a/apps/backend/src/tag/tag.entity.ts b/apps/backend/src/tag/tag.entity.ts index c9d697a4..cc780ddc 100644 --- a/apps/backend/src/tag/tag.entity.ts +++ b/apps/backend/src/tag/tag.entity.ts @@ -1,6 +1,15 @@ -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn +} from 'typeorm'; import { Lotus } from '@/lotus/lotus.entity'; -import { User } from '@/user/user.entity'; +import { LotusTag } from '@/relation/lotus.tag.entity'; @Entity() export class Tag { @@ -14,6 +23,6 @@ export class Tag { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; - @ManyToMany(() => Lotus, (lotus) => lotus.tags) - lotuses: Lotus[]; + @OneToMany(() => LotusTag, (lotusTag) => lotusTag.tag, { cascade: ['remove'] }) + lotuses: LotusTag[]; } diff --git a/apps/backend/src/tag/tag.service.ts b/apps/backend/src/tag/tag.service.ts index c3a2e8d1..4d4a0ea9 100644 --- a/apps/backend/src/tag/tag.service.ts +++ b/apps/backend/src/tag/tag.service.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; import { Tag } from './tag.entity'; import { TagRepository } from './tag.repository'; @@ -35,4 +36,14 @@ export class TagService { const tagIds = tags.map((tag) => tag.tagId); return tagIds; } + + async searchTagNames(tagName: string): Promise { + const tags = await this.tagRepository.searchTagName(tagName); + const tagNames = tags.map((tag) => tag.tagName); + return tagNames; + } + + async deleteNoRelationTags(tagId: string[]) { + return await this.tagRepository.delete({ tagId: Not(In(tagId)) }); + } } diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts index 1c6ae2bd..85a3235a 100644 --- a/apps/backend/src/user/user.controller.ts +++ b/apps/backend/src/user/user.controller.ts @@ -1,153 +1,153 @@ -import { - Body, - Controller, - DefaultValuePipe, - Get, - HttpCode, - Param, - ParseIntPipe, - Patch, - Query, - Redirect, - Req -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiBody, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { Request } from 'express'; -import { FileDto } from './dto/file.dto'; -import { FileResponseDto } from './dto/file.response.dto'; -import { TokenDTO } from './dto/token.dto'; -import { UserCreateDto } from './dto/user.create.dto'; -import { UserPatchDTO } from './dto/user.patch.dto'; -import { UserService } from './user.service'; -import { AuthService } from '@/auth/auth.service'; -import { ResponseAllGistsDto } from '@/gist/dto/response.allGists.dto'; -import { GistService } from '@/gist/gist.service'; -import { LotusPublicDto } from '@/lotus/dto/lotus.public.dto'; -import { SimpleUserResponseDto } from '@/lotus/dto/simple.user.response.dto'; -import { LotusService } from '@/lotus/lotus.service'; - -@Controller('/user') -export class UserController { - constructor( - private readonly userService: UserService, - private readonly lotusService: LotusService, - private readonly authService: AuthService, - private readonly gistService: GistService, - private configService: ConfigService - ) {} - private OAUTH_CLIENT_ID = this.configService.get('OAUTH_CLIENT_ID'); - private OAUTH_CLIENT_SECRETS = this.configService.get('OAUTH_CLIENT_SECRETS'); - private OAUTH_LOGIN_CALLBACK_URL = this.configService.get('OAUTH_LOGIN_CALLBACK_URL'); - - private TEST_GIT_TOKEN = this.configService.get('TEST_GIT_TOKEN'); - private TEST_GIT_LOGIN = this.configService.get('TEST_GIT_LOGIN'); - private TEST_GIT_PROFILE = this.configService.get('TEST_GIT_PROFILE'); - private TEST_GIT_ID = this.configService.get('TEST_GIT_ID'); - - @Get('test') - @HttpCode(200) - @ApiOperation({ summary: 'test용 access token 발급' }) - @ApiResponse({ status: 200, description: '실행 성공', type: TokenDTO }) - async testLogin(): Promise { - let testUser = await this.userService.findOne(this.TEST_GIT_ID); - if (!testUser) { - await this.userService.saveUser( - new UserCreateDto( - { - login: this.TEST_GIT_LOGIN, - avatar_url: this.TEST_GIT_PROFILE, - id: this.TEST_GIT_ID - }, - this.TEST_GIT_TOKEN - ) - ); - testUser = await this.userService.findOne(this.TEST_GIT_ID); - } - return TokenDTO.of(await this.userService.makeTestUser(testUser)); - } - - @Get('login') - @Redirect() - getGithubLoginPage() { - const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${this.OAUTH_CLIENT_ID}&redirect_uri=${this.OAUTH_LOGIN_CALLBACK_URL}&scope=gist`; - return { url: githubAuthUrl, statusCode: 301 }; - } - - @Get('login/callback') - async githubCallback(@Query('code') code: string) { - const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - client_id: this.OAUTH_CLIENT_ID, - client_secret: this.OAUTH_CLIENT_SECRETS, - code - }) - }); - const tokenData = await tokenResponse.json(); - const token = await this.userService.loginUser(tokenData); - return { token }; - } - - @Get('/lotus') - @HttpCode(200) - @ApiOperation({ summary: '사용자 lotus 목록 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: LotusPublicDto }) - @ApiQuery({ name: 'page', type: String, example: '1', required: false }) - @ApiQuery({ name: 'size', type: String, example: '10', required: false }) - getUserLotus( - @Req() request: Request, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number - ): Promise { - const userId = this.authService.getIdFromRequest(request); - return this.lotusService.getUserLotus(userId, page, size); - } - - @Get('') - @HttpCode(200) - @ApiOperation({ summary: '사용자 정보 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: SimpleUserResponseDto }) - getUserInfo(@Req() request: Request): Promise { - const userId = this.authService.getIdFromRequest(request); - return this.userService.getSimpleUserInfoByUserId(userId); - } - - @Patch('') - @HttpCode(200) - @ApiOperation({ summary: '사용자 정보 수정하기' }) - @ApiBody({ type: UserPatchDTO }) - @ApiResponse({ status: 200, description: '실행 성공', type: UserPatchDTO }) - patchUserInfo(@Req() request: Request, @Body() userData: UserPatchDTO): Promise { - const userId = this.authService.getIdFromRequest(request); - return this.userService.patchUserDataByUserId(userId, userData); - } - - @Get('gist') - @HttpCode(200) - @ApiOperation({ summary: '사용자의 gist 목록 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: ResponseAllGistsDto }) - async getGistPage( - @Req() request: Request, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number - ): Promise { - const gitToken = await this.authService.getUserGitToken(this.authService.getIdFromRequest(request)); - return await this.gistService.getGistList(gitToken, page, size); - } - - @Get('gist/:gistId') - @HttpCode(200) - @ApiOperation({ summary: '사용자의 특정 gist의 내부 파일 데이터 가져오기' }) - @ApiResponse({ status: 200, description: '실행 성공', type: FileResponseDto }) - async getCommitFile(@Req() request: Request, @Param('gistId') gistId: string) { - const gitToken = await this.authService.getUserGitToken(this.authService.getIdFromRequest(request)); - const Files = await this.gistService.getGistById(gistId, gitToken); - const result = Files.files.map((file) => FileDto.ofGistApiFile(file)); - return FileResponseDto.ofFiles(result); - } -} +import { + Body, + Controller, + DefaultValuePipe, + Get, + HttpCode, + Param, + ParseIntPipe, + Patch, + Query, + Redirect, + Req +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiBody, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { Request } from 'express'; +import { FileDto } from './dto/file.dto'; +import { FileResponseDto } from './dto/file.response.dto'; +import { TokenDTO } from './dto/token.dto'; +import { UserCreateDto } from './dto/user.create.dto'; +import { UserPatchDTO } from './dto/user.patch.dto'; +import { UserService } from './user.service'; +import { AuthService } from '@/auth/auth.service'; +import { ResponseAllGistsDto } from '@/gist/dto/response.allGists.dto'; +import { GistService } from '@/gist/gist.service'; +import { LotusPublicDto } from '@/lotus/dto/lotus.public.dto'; +import { SimpleUserResponseDto } from '@/lotus/dto/simple.user.response.dto'; +import { LotusService } from '@/lotus/lotus.service'; + +@Controller('/user') +export class UserController { + constructor( + private readonly userService: UserService, + private readonly lotusService: LotusService, + private readonly authService: AuthService, + private readonly gistService: GistService, + private configService: ConfigService + ) {} + private OAUTH_CLIENT_ID = this.configService.get('OAUTH_CLIENT_ID'); + private OAUTH_CLIENT_SECRETS = this.configService.get('OAUTH_CLIENT_SECRETS'); + private OAUTH_LOGIN_CALLBACK_URL = this.configService.get('OAUTH_LOGIN_CALLBACK_URL'); + + private TEST_GIT_TOKEN = this.configService.get('TEST_GIT_TOKEN'); + private TEST_GIT_LOGIN = this.configService.get('TEST_GIT_LOGIN'); + private TEST_GIT_PROFILE = this.configService.get('TEST_GIT_PROFILE'); + private TEST_GIT_ID = this.configService.get('TEST_GIT_ID'); + + @Get('test') + @HttpCode(200) + @ApiOperation({ summary: 'test용 access token 발급' }) + @ApiResponse({ status: 200, description: '실행 성공', type: TokenDTO }) + async testLogin(): Promise { + let testUser = await this.userService.findOne(this.TEST_GIT_ID); + if (!testUser) { + await this.userService.saveUser( + new UserCreateDto( + { + login: this.TEST_GIT_LOGIN, + avatar_url: this.TEST_GIT_PROFILE, + id: this.TEST_GIT_ID + }, + this.TEST_GIT_TOKEN + ) + ); + testUser = await this.userService.findOne(this.TEST_GIT_ID); + } + return TokenDTO.of(await this.userService.makeTestUser(testUser)); + } + + @Get('login') + @Redirect() + getGithubLoginPage() { + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${this.OAUTH_CLIENT_ID}&redirect_uri=${this.OAUTH_LOGIN_CALLBACK_URL}&scope=gist`; + return { url: githubAuthUrl, statusCode: 301 }; + } + + @Get('login/callback') + async githubCallback(@Query('code') code: string) { + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_id: this.OAUTH_CLIENT_ID, + client_secret: this.OAUTH_CLIENT_SECRETS, + code + }) + }); + const tokenData = await tokenResponse.json(); + const token = await this.userService.loginUser(tokenData); + return { token }; + } + + @Get('/lotus') + @HttpCode(200) + @ApiOperation({ summary: '사용자 lotus 목록 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: LotusPublicDto }) + @ApiQuery({ name: 'page', type: String, example: '1', required: false }) + @ApiQuery({ name: 'size', type: String, example: '10', required: false }) + getUserLotus( + @Req() request: Request, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number + ): Promise { + const userId = this.authService.getIdFromRequest(request); + return this.lotusService.getUserLotus(userId, page, size); + } + + @Get('') + @HttpCode(200) + @ApiOperation({ summary: '사용자 정보 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: SimpleUserResponseDto }) + getUserInfo(@Req() request: Request): Promise { + const userId = this.authService.getIdFromRequest(request); + return this.userService.getSimpleUserInfoByUserId(userId); + } + + @Patch('') + @HttpCode(200) + @ApiOperation({ summary: '사용자 정보 수정하기' }) + @ApiBody({ type: UserPatchDTO }) + @ApiResponse({ status: 200, description: '실행 성공', type: UserPatchDTO }) + patchUserInfo(@Req() request: Request, @Body() userData: UserPatchDTO): Promise { + const userId = this.authService.getIdFromRequest(request); + return this.userService.patchUserDataByUserId(userId, userData); + } + + @Get('gist') + @HttpCode(200) + @ApiOperation({ summary: '사용자의 gist 목록 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: ResponseAllGistsDto }) + async getGistPage( + @Req() request: Request, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(10), ParseIntPipe) size: number + ): Promise { + const gitToken = await this.authService.getUserGitToken(this.authService.getIdFromRequest(request)); + return await this.gistService.getGistList(gitToken, page, size); + } + + @Get('gist/:gistId') + @HttpCode(200) + @ApiOperation({ summary: '사용자의 특정 gist의 내부 파일 데이터 가져오기' }) + @ApiResponse({ status: 200, description: '실행 성공', type: FileResponseDto }) + async getCommitFile(@Req() request: Request, @Param('gistId') gistId: string) { + const gitToken = await this.authService.getUserGitToken(this.authService.getIdFromRequest(request)); + const Files = await this.gistService.getGistById(gistId, gitToken); + const result = Files.files.map((file) => FileDto.ofGistApiFile(file)); + return FileResponseDto.ofFiles(result); + } +} diff --git a/apps/backend/src/user/user.entity.ts b/apps/backend/src/user/user.entity.ts index cc4bca8a..74ae3207 100644 --- a/apps/backend/src/user/user.entity.ts +++ b/apps/backend/src/user/user.entity.ts @@ -4,8 +4,7 @@ import { Lotus } from '@/lotus/lotus.entity'; @Entity() export class User { - //@PrimaryGeneratedColumn('uuid', { type: 'bigint' }) - @PrimaryGeneratedColumn('increment', { type: 'bigint', name: 'user_id' }) + @PrimaryGeneratedColumn('uuid', { name: 'user_id' }) userId: string; @Column() @@ -23,9 +22,9 @@ export class User { @Column({ name: 'git_id', unique: true }) gitId: number; - @OneToMany(() => Lotus, (lotus) => lotus.user) + @OneToMany(() => Lotus, (lotus) => lotus.user, { cascade: true }) lotuses: Lotus[]; - @OneToMany(() => Comment, (comment) => comment.user) + @OneToMany(() => Comment, (comment) => comment.user, { cascade: true }) comments: Comment[]; } diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index ba8df4a9..dc8c9b29 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -1,93 +1,93 @@ -import { HttpException, HttpStatus, Inject, Injectable, forwardRef } from '@nestjs/common'; -import { isString } from 'class-validator'; -import { UserCreateDto } from './dto/user.create.dto'; -import { UserPatchDTO } from './dto/user.patch.dto'; -import { User } from './user.entity'; -import { UserRepository } from './user.repository'; -import { AuthService } from '@/auth/auth.service'; -import { SimpleUserResponseDto } from '@/lotus/dto/simple.user.response.dto'; - -@Injectable() -export class UserService { - constructor( - private readonly userRepository: UserRepository, - @Inject(forwardRef(() => AuthService)) - private authService: AuthService - ) {} - - async findOne(gitId: number): Promise { - return this.userRepository.findOneBy({ gitId }); - } - - async findOneByUserId(userId: string): Promise { - return this.userRepository.findOneBy({ userId }); - } - - async getSimpleUserInfoByUserId(userId: string): Promise { - const user = await this.userRepository.findOneBy({ userId }); - if (!user) { - throw new HttpException('user data is not found', HttpStatus.NOT_FOUND); - } - return SimpleUserResponseDto.ofUserDto(user); - } - - async patchUserDataByUserId(userId: string, updateData: UserPatchDTO): Promise { - const modifyingData = this.getObjUser(updateData); - - const result = await this.userRepository.update({ userId }, modifyingData); - if (!result.affected) { - throw new HttpException('user info not found', HttpStatus.NOT_FOUND); - } - const user = await this.userRepository.findOneBy({ userId }); - return UserPatchDTO.ofUser(user); - } - - getObjUser(updateData: UserPatchDTO) { - const obj: any = {}; - - if (updateData.nickname && isString(updateData.nickname)) { - obj.nickname = updateData.nickname; - } - if (updateData.profile) { - obj.profilePath = updateData.profile; - } - if (Object.keys(obj).length === 0) { - throw new HttpException('wrong user data', HttpStatus.BAD_REQUEST); - } - - return obj; - } - - async loginUser(tokenData) { - const accessToken = tokenData.access_token; - const userResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${accessToken}` - } - }); - const inputUser = await userResponse.json(); - let user = await this.findOne(inputUser.id); - if (!user) { - await this.saveUser(new UserCreateDto(inputUser, accessToken)); - user = await this.findOne(inputUser.id); - } else { - await this.userRepository.update({ gitId: inputUser.id }, { gitToken: accessToken }); - } - const token = this.authService.createJwt(user.userId); - return token; - } - - async makeTestUser(user: User) { - return this.authService.createJwt(user.userId); - } - - async saveUser(user: User): Promise { - await this.userRepository.save(user); - } - - async findUserGistToken(userId: string): Promise { - const foundUser = await this.userRepository.findOneBy({ userId }); - if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); - return foundUser.gitToken; - } -} +import { HttpException, HttpStatus, Inject, Injectable, forwardRef } from '@nestjs/common'; +import { isString } from 'class-validator'; +import { UserCreateDto } from './dto/user.create.dto'; +import { UserPatchDTO } from './dto/user.patch.dto'; +import { User } from './user.entity'; +import { UserRepository } from './user.repository'; +import { AuthService } from '@/auth/auth.service'; +import { SimpleUserResponseDto } from '@/lotus/dto/simple.user.response.dto'; + +@Injectable() +export class UserService { + constructor( + private readonly userRepository: UserRepository, + @Inject(forwardRef(() => AuthService)) + private authService: AuthService + ) {} + + async findOne(gitId: number): Promise { + return this.userRepository.findOneBy({ gitId }); + } + + async findOneByUserId(userId: string): Promise { + return this.userRepository.findOneBy({ userId }); + } + + async getSimpleUserInfoByUserId(userId: string): Promise { + const user = await this.userRepository.findOneBy({ userId }); + if (!user) { + throw new HttpException('user data is not found', HttpStatus.NOT_FOUND); + } + return SimpleUserResponseDto.ofUserDto(user); + } + + async patchUserDataByUserId(userId: string, updateData: UserPatchDTO): Promise { + const modifyingData = this.getObjUser(updateData); + + const result = await this.userRepository.update({ userId }, modifyingData); + if (!result.affected) { + throw new HttpException('user info not found', HttpStatus.NOT_FOUND); + } + const user = await this.userRepository.findOneBy({ userId }); + return UserPatchDTO.ofUser(user); + } + + getObjUser(updateData: UserPatchDTO) { + const obj: any = {}; + + if (updateData.nickname && isString(updateData.nickname)) { + obj.nickname = updateData.nickname; + } + if (updateData.profile) { + obj.profilePath = updateData.profile; + } + if (Object.keys(obj).length === 0) { + throw new HttpException('wrong user data', HttpStatus.BAD_REQUEST); + } + + return obj; + } + + async loginUser(tokenData) { + const accessToken = tokenData.access_token; + const userResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + const inputUser = await userResponse.json(); + let user = await this.findOne(inputUser.id); + if (!user) { + await this.saveUser(new UserCreateDto(inputUser, accessToken)); + user = await this.findOne(inputUser.id); + } else { + await this.userRepository.update({ gitId: inputUser.id }, { gitToken: accessToken }); + } + const token = this.authService.createJwt(user.userId); + return token; + } + + async makeTestUser(user: User) { + return this.authService.createJwt(user.userId); + } + + async saveUser(user: User): Promise { + await this.userRepository.save(user); + } + + async findUserGistToken(userId: string): Promise { + const foundUser = await this.userRepository.findOneBy({ userId }); + if (!foundUser) throw new HttpException('user data not found', HttpStatus.NOT_FOUND); + return foundUser.gitToken; + } +} diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index a547bf36..b88c8135 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 74e02486..06de9472 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,11 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:report": "playwright show-report" }, "lint-staged": { "*.{ts,tsx}": [ @@ -33,11 +37,13 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@playwright/test": "^1.49.0", "@tanstack/react-query-devtools": "^5.59.19", "@tanstack/router-devtools": "^1.78.3", "@tanstack/router-plugin": "^1.78.3", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/parser": "^5.59.11", @@ -67,4 +73,4 @@ "public" ] } -} \ No newline at end of file +} diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts new file mode 100644 index 00000000..9e21df9e --- /dev/null +++ b/apps/frontend/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/app/test/e2e', + fullyParallel: true, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173' + } +}); diff --git a/apps/frontend/src/app/mock/MockRepository/MockRepository.ts b/apps/frontend/src/app/mock/MockRepository/MockRepository.ts index 7482f989..20f6646a 100644 --- a/apps/frontend/src/app/mock/MockRepository/MockRepository.ts +++ b/apps/frontend/src/app/mock/MockRepository/MockRepository.ts @@ -16,10 +16,36 @@ export class MockRepository { return true; } + private isPartialMatch(owner: Partial, target: Partial): boolean { + for (const key in target) { + if (!Object.prototype.hasOwnProperty.call(owner, key)) return false; + + const ownerValue = owner[key as keyof T]; + const targetValue = target[key as keyof T]; + + if (typeof ownerValue === 'boolean' && ownerValue !== targetValue) { + return false; + } + + if (typeof targetValue === 'string' && !(ownerValue as string)?.includes(targetValue)) { + return false; + } + } + return true; + } + private generateId() { return String(this._autoId++); } + private paginate(items: Identifiable[], page: number, size: number) { + const start = (page - 1) * size; + const end = start + size; + const data = items.slice(start, end); + const maxPage = Math.ceil(items.length / size); + return { data, maxPage }; + } + async create(arg: T) { const data = { ...arg, id: this.generateId() }; @@ -52,10 +78,7 @@ export class MockRepository { async findMany({ query, page = 1, size = 10 }: { query?: Partial>; page?: number; size?: number }) { const filtered = query ? this.memory.filter((item) => this.isMatch(item, query)) : this.memory; - const start = (page - 1) * size; - const end = start + size; - - return filtered.slice(start, end); + return this.paginate(filtered, page, size); } async findOne(query: Partial>) { @@ -65,4 +88,9 @@ export class MockRepository { return data; } + + async search({ query, page = 1, size = 10 }: { query?: Partial>; page?: number; size?: number }) { + const filtered = query ? this.memory.filter((item) => this.isPartialMatch(item, query)) : this.memory; + return this.paginate(filtered, page, size); + } } diff --git a/apps/frontend/src/app/mock/gistResolvers.ts b/apps/frontend/src/app/mock/gistResolvers.ts index 13b6e482..cb6317d9 100644 --- a/apps/frontend/src/app/mock/gistResolvers.ts +++ b/apps/frontend/src/app/mock/gistResolvers.ts @@ -1,7 +1,7 @@ import { HttpResponse, PathParams } from 'msw'; // 사용자의 Gist 목록 조회 -export const mockGetUserGistList = () => { +export const getUserGistList = () => { return HttpResponse.json({ gists: [ { @@ -27,7 +27,7 @@ export const mockGetUserGistList = () => { }; // 특정 Gist 파일 조회 api -export const mockGetGistDetail = ({ params }: { params: PathParams }) => { +export const getGistDetail = ({ params }: { params: PathParams }) => { const { gistId } = params; if (!gistId) { diff --git a/apps/frontend/src/app/mock/handlers.ts b/apps/frontend/src/app/mock/handlers.ts index ff9cb540..73504cf5 100644 --- a/apps/frontend/src/app/mock/handlers.ts +++ b/apps/frontend/src/app/mock/handlers.ts @@ -1,38 +1,37 @@ import { http } from 'msw'; -import { mockGetGistDetail, mockGetUserGistList } from './gistResolvers'; +import { getGistDetail, getUserGistList } from './gistResolvers'; +import { getHistory, getHistoryList, getTagList, postCodeRun, postTag } from './historyResolvers'; import { - mockGetHistory, - mockGetHistoryList, - mockGetTagList, - mockGetUserLotusList, - mockPostCodeRun, - mockPostTag -} from './historyResolvers'; -import { deleteLotus, getLotusDetail, getPublicLotusList, patchLotus, postCreateLotus } from './lotusResolvers'; -import { mockGetLogin, mockGetUserInfo, mockLogin, mockLogout, mockPatchUserInfo } from './userResolvers'; + deleteLotus, + getLotusDetail, + getPublicLotusList, + getUserLotusList, + patchLotus, + postCreateLotus +} from './lotusResolvers'; +import { getLogin, getUserInfo, patchUserInfo } from './userResolvers'; + +const apiUrl = (path: string) => `${import.meta.env.VITE_API_URL}${path}`; export const handlers = [ // user - http.get(`/api/user`, mockGetUserInfo), - http.patch(`/api/user`, mockPatchUserInfo), - http.post(`/api/user/login`, mockLogin), - http.get(`/api/user/login`, mockGetLogin), - http.post(`/api/user/logout`, mockLogout), - http.get(`/api/user/lotus`, mockGetUserLotusList), - http.get(`/api/user/gist`, mockGetUserGistList), - http.get(`/api/user/gist/:gistId`, mockGetGistDetail), + http.get(apiUrl(`/api/user`), getUserInfo), + http.patch(apiUrl(`/api/user`), patchUserInfo), + http.get(apiUrl(`/api/user/login/callback`), getLogin), + http.get(apiUrl(`/api/user/lotus`), getUserLotusList), + http.get(apiUrl(`/api/user/gist`), getUserGistList), + http.get(apiUrl(`/api/user/gist/:gistId`), getGistDetail), // lotus - http.get(`/api/lotus`, getPublicLotusList), - http.get(`/api/lotus/:lotusId`, getLotusDetail), - http.post(`/api/lotus`, postCreateLotus), - http.patch(`/api/lotus/:id`, patchLotus), - http.delete(`/api/lotus/:id`, deleteLotus), + http.get(apiUrl(`/api/lotus`), getPublicLotusList), + http.get(apiUrl(`/api/lotus/:lotusId`), getLotusDetail), + http.post(apiUrl(`/api/lotus`), postCreateLotus), + http.patch(apiUrl(`/api/lotus/:id`), patchLotus), + http.delete(apiUrl(`/api/lotus/:id`), deleteLotus), // history - http.get(`/api/lotus/:lotusId/history`, mockGetHistoryList), - http.post(`/api/lotus/:lotusId/history`, mockPostCodeRun), - http.get(`/api/lotus/:lotusId/history/:historyId`, mockGetHistory), - + http.get(apiUrl(`/api/lotus/:lotusId/history`), getHistoryList), + http.post(apiUrl(`/api/lotus/:lotusId/history`), postCodeRun), + http.get(apiUrl(`/api/lotus/:lotusId/history/:historyId`), getHistory), // tag - http.get(`/api/tag`, mockGetTagList), - http.post(`/api/tag`, mockPostTag) + http.get(apiUrl(`/api/tag`), getTagList), + http.post(apiUrl(`/api/tag`), postTag) ]; diff --git a/apps/frontend/src/app/mock/historyResolvers.ts b/apps/frontend/src/app/mock/historyResolvers.ts index a8d937b2..453de15c 100644 --- a/apps/frontend/src/app/mock/historyResolvers.ts +++ b/apps/frontend/src/app/mock/historyResolvers.ts @@ -1,166 +1,33 @@ import { DefaultBodyType, HttpResponse, PathParams, StrictRequest } from 'msw'; import { MockRepository } from './MockRepository'; +import { HistoryDto } from '@/feature/history'; -// 사용자의 Lotus 목록 조회 -export const mockGetUserLotusList = () => { - return HttpResponse.json({ - lotuses: [ - { - id: '10000000001', - title: 'Exploring the Depths of TypeScript', - tags: ['TypeScript', 'Programming', 'Web Development'], - language: 'English', - date: '2024-11-16', - author: { - id: '20000000001', - nickname: 'dev_master', - profile: '/image/exampleImage.jpeg' - } - }, - { - id: '10000000002', - title: 'Understanding React Hooks', - tags: ['React', 'JavaScript', 'Frontend'], - language: 'English', - date: '2024-11-15', - author: { - id: '20000000002', - nickname: 'react_enthusiast', - profile: '/image/exampleImage.jpegt' - } - } - ], - page: { - current: 1, - max: 5 - } - }); -}; - -// public lotus 목록 조회 -export const mockGetPublicLotusList = ({ request }: { request: StrictRequest }) => { - const url = new URL(request.url); - const page = Number(url.searchParams.get('page')) || 0; - // const size = Number(url.searchParams.get('size')) || 10; - // const search = url.searchParams.get('search') || ""; - - return HttpResponse.json({ - lotuses: [ - { - id: '10000000001', - title: 'Exploring the Depths of TypeScript', - tags: ['TypeScript', 'Programming', 'Web Development'], - language: 'English', - date: '2024-11-16', - author: { - id: '20000000001', - nickname: 'dev_master', - profile: 'https://example.com/profiles/dev_master' - } - }, - { - id: '10000000002', - title: 'Understanding React Hooks', - tags: ['React', 'JavaScript', 'Frontend'], - language: 'English', - date: '2024-11-15', - author: { - id: '20000000002', - nickname: 'react_enthusiast', - profile: 'https://example.com/profiles/react_enthusiast' - } - } - ], - page: { - current: page, - max: 5 - } - }); -}; - -// 특정 Lotus 조회 -export const mockGetLotusDetail = ({ - request, - params -}: { - request: StrictRequest; - params: PathParams; -}) => { - const authorization = request.headers.get('Authorization'); - - if (!authorization || !authorization.startsWith('Bearer ')) { - return new HttpResponse('Unauthorized: Invalid or missing token', { - status: 401, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - const { lotusId } = params; - - if (!lotusId) { - return new HttpResponse('Bad Request', { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - return HttpResponse.json({ - id: '10000000001', - title: 'Exploring the Depths of TypeScript', - tags: ['TypeScript', 'Programming', 'Web Development'], - language: 'English', - date: '2024-11-16', - isPublic: true, - author: { - id: '20000000001', - nickname: 'dev_master', - profile: 'https://example.com/profiles/dev_master' - }, - files: [ - { - filename: 'index.tsx', - language: 'ts', - content: 'const a = 1;\nconsole.log(a);\n' - } - ] - }); -}; - -interface HistoryType { - status: 'SUCCESS' | 'ERROR' | 'PENDING'; - date: string; - filename: string; - input?: string[]; - output?: string; -} - -const historyList = new MockRepository(); +const historyList = new MockRepository>(); const insertHistory = () => { - const historyMock: HistoryType[] = [ + const historyMock: HistoryDto[] = [ { + id: '2000001', status: 'SUCCESS', date: '2024-11-15T14:30:00Z', filename: 'main.js', - input: ['1', '2'], + input: '11', output: '3' }, { + id: '2000002', status: 'SUCCESS', date: '2024-11-16T12:00:00Z', filename: 'index.js', - input: ['3', '4'], + input: '12', output: 'console.log(7)' }, { + id: '2000003', status: 'ERROR', date: '2024-11-14T16:45:00Z', filename: 'main.js', - input: ['5', '6'], + input: '13', output: 'Error: Cannot find module' } ]; @@ -173,7 +40,7 @@ const insertHistory = () => { insertHistory(); // Lotus History 목록 조회 -export const mockGetHistoryList = async ({ +export const getHistoryList = async ({ request, params }: { @@ -193,25 +60,25 @@ export const mockGetHistoryList = async ({ } }); } - - const list = await historyList.findMany({ page }); + const { data, maxPage: max } = await historyList.findMany({ page }); + const list = data.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return HttpResponse.json({ list, page: { current: page, - max: 3 + max } }); }; // 코드 실행 interface PostCodeRunBody { - input?: string[]; + input?: string; execFileName: string; } -export const mockPostCodeRun = async ({ request }: { request: StrictRequest }) => { +export const postCodeRun = async ({ request }: { request: StrictRequest }) => { const body = (await request.json()) as PostCodeRunBody; if (!body?.execFileName) @@ -226,7 +93,8 @@ export const mockPostCodeRun = async ({ request }: { request: StrictRequest { @@ -245,7 +113,7 @@ export const mockPostCodeRun = async ({ request }: { request: StrictRequest { +export const getHistory = async ({ params }: { params: PathParams }) => { const { lotusId, historyId } = params; if (!lotusId || !historyId) { @@ -262,165 +130,8 @@ export const mockGetHistory = async ({ params }: { params: PathParams }) => { return HttpResponse.json(history); }; -// Lotus 생성 -interface PostCreateLotusBody { - title: string; - isPublic: boolean; - tag: string[]; - gistUuid: string; -} - -export const mockPostCreateLotus = async ({ request }: { request: StrictRequest }) => { - const authorization = request.headers.get('Authorization'); - - if (!authorization || !authorization.startsWith('Bearer ')) { - return new HttpResponse('Unauthorized: Invalid or missing token', { - status: 401, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - try { - const body = (await request.json()) as PostCreateLotusBody; - - if (!body) throw new Error('body 형식이 올바르지 않음'); - - return HttpResponse.json({ - id: '1234567890', - user: { - id: '9876543210', - nickname: 'coding_expert', - profile: 'https://example.com/profiles/coding_expert' - }, - title: 'Understanding TypeScript Generics', - isPublic: true, - createAt: '2024-11-16T10:00:00Z', - tag: [ - { - tagName: 'TypeScript' - }, - { - tagName: 'Generics' - }, - { - tagName: 'Programming' - } - ] - }); - } catch (error) { - console.error(error); - return new HttpResponse('Bad Request', { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); - } -}; - -// Lotus 수정 -interface PatchLotusBody { - title: string; - tag: string[]; - isPublic: boolean; -} - -export const mockPatchLotus = async ({ - request, - params -}: { - request: StrictRequest; - params: PathParams; -}) => { - const authorization = request.headers.get('Authorization'); - - if (!authorization || !authorization.startsWith('Bearer ')) { - return new HttpResponse('Unauthorized: Invalid or missing token', { - status: 401, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - try { - const { id } = params; - const body = (await request.json()) as PatchLotusBody; - - if (!body || !id) throw new Error('요청 형식이 올바르지 않음'); - - return HttpResponse.json({ - id: '1234567890', - user: { - id: '9876543210', - nickname: 'coding_expert', - profile: 'https://example.com/profiles/coding_expert' - }, - title: 'Understanding TypeScript Generics', - isPublic: true, - createAt: '2024-11-16T10:00:00Z', - tag: [ - { - tagName: 'TypeScript' - }, - { - tagName: 'Generics' - }, - { - tagName: 'Programming' - } - ] - }); - } catch (error) { - console.error(error); - return new HttpResponse('Bad Request', { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); - } -}; - -// Lotus 삭제 -export const mockDeleteLotus = async ({ - request, - params -}: { - request: StrictRequest; - params: PathParams; -}) => { - const authorization = request.headers.get('Authorization'); - - if (!authorization || !authorization.startsWith('Bearer ')) { - return new HttpResponse('Unauthorized: Invalid or missing token', { - status: 401, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - const { id } = params; - - if (!id) { - return new HttpResponse('Bad Request', { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); - } - - return HttpResponse.json({ - message: '삭제 성공' - }); -}; - // 태그 조회 -export const mockGetTagList = ({ request }: { request: StrictRequest }) => { +export const getTagList = ({ request }: { request: StrictRequest }) => { const authorization = request.headers.get('Authorization'); if (!authorization || !authorization.startsWith('Bearer ')) { @@ -464,7 +175,7 @@ interface PostTagBody { tag: string; } -export const mockPostTag = async ({ request }: { request: StrictRequest }) => { +export const postTag = async ({ request }: { request: StrictRequest }) => { const authorization = request.headers.get('Authorization'); if (!authorization || !authorization.startsWith('Bearer ')) { diff --git a/apps/frontend/src/app/mock/lotusResolvers.ts b/apps/frontend/src/app/mock/lotusResolvers.ts index 5aada82e..ba95478c 100644 --- a/apps/frontend/src/app/mock/lotusResolvers.ts +++ b/apps/frontend/src/app/mock/lotusResolvers.ts @@ -7,19 +7,56 @@ const lotusList = new MockRepository> insertLotus(); +const MOCK_UUID = 'mock-uuid'; + +// 사용자의 Lotus 목록 조회 +export const getUserLotusList = async ({ request }: { request: StrictRequest }) => { + const authorization = request.headers.get('Authorization'); + + const [type, token] = authorization?.split(' ') || []; + + if (token !== MOCK_UUID || type !== 'Bearer') { + return new HttpResponse('Unauthorized: Invalid or missing token', { + status: 401, + headers: { + 'Content-Type': 'text/plain' + } + }); + } + + const url = new URL(request.url); + const page = Number(url.searchParams.get('page')) || 1; + const size = Number(url.searchParams.get('size')) || 5; + + const { data: lotuses, maxPage: max } = await lotusList.findMany({ page, size }); + + return HttpResponse.json({ + lotuses, + page: { + current: page, + max + } + }); +}; + // public lotus 목록 조회 export const getPublicLotusList = async ({ request }: { request: StrictRequest }) => { const url = new URL(request.url); const page = Number(url.searchParams.get('page')) || 1; const size = Number(url.searchParams.get('size')) || 5; + const search = url.searchParams.get('search') || ''; - const lotuses = await lotusList.findMany({ page, size }); + const { data: lotuses, maxPage: max } = await lotusList.search({ + query: { title: search, isPublic: true }, + page, + size + }); return HttpResponse.json({ lotuses, page: { current: page, - max: 5 + max } }); }; @@ -123,7 +160,6 @@ export const patchLotus = async ({ }; // lotus 삭제 - export const deleteLotus = async ({ params }: { params: PathParams }) => { const { id } = params; @@ -151,7 +187,7 @@ function insertLotus() { date: new Date('2024-11-01').toISOString(), tags: ['JavaScript', 'Closures', 'Web Development'], author: { - id: '1', + id: '0', nickname: 'js_master', profile: 'https://devblog.com/authors/js_master', gistUrl: '' diff --git a/apps/frontend/src/app/mock/userResolvers.ts b/apps/frontend/src/app/mock/userResolvers.ts index 0f79a652..1dd517f3 100644 --- a/apps/frontend/src/app/mock/userResolvers.ts +++ b/apps/frontend/src/app/mock/userResolvers.ts @@ -1,15 +1,31 @@ import { DefaultBodyType, HttpResponse, StrictRequest } from 'msw'; +import { MockRepository } from './MockRepository'; +import { UserDto } from '@/feature/user'; +const MOCK_CODE = 'mock-code'; const MOCK_UUID = 'mock-uuid'; +const userList = new MockRepository>(); + +const insertUser = () => { + const userMock: UserDto = { + id: '1', + nickname: 'mockUser', + profile: '/image/exampleImage.jpeg', + gistUrl: 'https://github.com' + }; + + userList.create(userMock); +}; + +insertUser(); + // github 사용자 기본 정보 조회 api -export const mockGetUserInfo = ({ request }: { request: StrictRequest }) => { +export const getUserInfo = async ({ request }: { request: StrictRequest }) => { const authorization = request.headers.get('Authorization'); const [type, token] = authorization?.split(' ') || []; - console.log('type', type, 'token', token, 'MOCK_UUID', MOCK_UUID, token === MOCK_UUID); - if (token !== MOCK_UUID || type !== 'Bearer') { return new HttpResponse('Unauthorized: Invalid or missing token', { status: 401, @@ -19,20 +35,14 @@ export const mockGetUserInfo = ({ request }: { request: StrictRequest }) => { +export const patchUserInfo = async ({ request }: { request: StrictRequest }) => { const authorization = request.headers.get('Authorization'); if (!authorization || !authorization.startsWith('Bearer ')) { @@ -45,14 +55,13 @@ export const mockPatchUserInfo = async ({ request }: { request: StrictRequest; - if (!body.nickname) throw new Error('body 형식이 올바르지 않음'); + const user = await userList.findOne({ id: '0' }); - return HttpResponse.json({ - nickname: body.nickname, - profile: `https://github.com/${body.nickname}` - }); + const updatedUser = await userList.update(user, body); + + return HttpResponse.json(updatedUser); } catch (error) { console.error(error); return new HttpResponse('Bad Request', { @@ -65,36 +74,19 @@ export const mockPatchUserInfo = async ({ request }: { request: StrictRequest { - return HttpResponse.json({ - token: MOCK_UUID - }); -}; - -// 로그인 api -export const mockGetLogin = () => { - return new HttpResponse(null, { - status: 302, - headers: { - Location: `/login/success?token=${MOCK_UUID}` - } - }); -}; - -// 로그아웃 api -export const mockLogout = ({ request }: { request: StrictRequest }) => { - const authorization = request.headers.get('Authorization'); +export const getLogin = ({ request }: { request: StrictRequest }) => { + const url = new URL(request.url); + const code = url.searchParams.get('code'); - if (!authorization || !authorization.startsWith('Bearer ')) { - return new HttpResponse('Unauthorized: Invalid or missing token', { + if (code !== MOCK_CODE) + return new HttpResponse('Unauthorized: Invalid or missing code', { status: 401, headers: { 'Content-Type': 'text/plain' } }); - } return HttpResponse.json({ - message: '로그아웃 성공!' + token: MOCK_UUID }); }; diff --git a/apps/frontend/src/app/test/e2e/lotusDetail.spec.ts b/apps/frontend/src/app/test/e2e/lotusDetail.spec.ts new file mode 100644 index 00000000..11874551 --- /dev/null +++ b/apps/frontend/src/app/test/e2e/lotusDetail.spec.ts @@ -0,0 +1,109 @@ +import test, { expect } from '@playwright/test'; + +test('Lotus 상세 페이지에서 코드 실행을 하고 결과를 확인할 수 있다.', async ({ page }) => { + // Given + await page.goto('/lotus'); + const lotusLink = page.getByTestId('lotus-link').first(); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // When + await page.getByRole('button', { name: '실행하기' }).click(); + await page.getByRole('combobox').click(); + await page.getByRole('listbox').click(); + await page.getByRole('button', { name: '새로운 항목 추가' }).click(); + await page.getByPlaceholder('Input').fill('12'); + await page.getByRole('button', { name: '새로운 항목 추가' }).click(); + await page.getByPlaceholder('Input 2').click(); + await page.getByPlaceholder('Input 2').fill('23'); + await page.locator('form').getByRole('paragraph').nth(1).click(); + await page.locator('section').getByRole('button', { name: '실행하기' }).click(); + + // Then + await page.waitForSelector('[data-testid="history-status"]'); + await expect(page.getByText('코드가 실행되었습니다.')).toBeVisible(); + const codeRunStatus = page.getByTestId('history-status').first(); + expect(await codeRunStatus.textContent()).toBe('pending'); +}); + +test('Lotus 상세 페이지에서 제목과 태그를 수정할 수 있다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + const lotusLink = page.getByTestId('lotus-link').first(); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // When + await page.getByRole('button', { name: '수정하기' }).click(); + await page.getByPlaceholder('제목을 입력해주세요').click(); + await page.getByPlaceholder('제목을 입력해주세요').fill('New Title'); + await page + .locator('form div') + .filter({ hasText: /^JavaScript$/ }) + .getByRole('button') + .click(); + await page.getByPlaceholder('태그를 입력해주세요').click(); + await page.getByPlaceholder('태그를 입력해주세요').fill('new Tag'); + await page.getByPlaceholder('태그를 입력해주세요').press('Enter'); + await page.locator('form').getByRole('button', { name: '수정하기' }).click(); + + // Then + await expect(page.getByText('New Title')).toBeVisible(); + await expect(page.getByText('JavaScript')).toBeHidden(); + await expect(page.getByText('new Tag')).toBeVisible(); + await expect(page.getByText('Lotus가 수정되었습니다.')).toBeVisible(); +}); + +test('Lotus 상세 페이지에서 삭제할 수 있다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + const lotusLink = page.getByTestId('lotus-link').first(); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // When + const deleteButton = page.getByRole('button', { name: '삭제하기' }); + await deleteButton.waitFor({ state: 'visible' }); + const deletedTitle = await page.getByTestId('lotus-title').textContent(); + await deleteButton.click(); + + await expect(page.getByText('정말로 삭제하시겠습니까?')).toBeVisible(); + await page.getByRole('button', { name: '삭제하기' }).nth(1).click(); + + // Then + await expect(page.getByText('Lotus는 gist 저장소들을 의미합니다')).toBeVisible(); + await page.waitForSelector('[data-testid="lotus-title"]'); + + const lotusTitles = await page.getByTestId('lotus-title').allTextContents(); + const isDelete = lotusTitles.every((value) => value !== deletedTitle); + expect(isDelete).toBe(true); + await expect(page.getByText('Lotus가 삭제되었습니다.')).toBeVisible(); +}); + +test('Lotus 상세 페이지에서 public을 Private으로 바꾸면 Lotus 목록 페이지에서 사라진다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + const lotusLink = page.getByTestId('lotus-link').first(); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // When + const publicSwitch = page.getByRole('switch'); + await publicSwitch.waitFor({ state: 'visible' }); + await publicSwitch.click(); + await page.waitForTimeout(3000); + + const targetTitle = await page.getByTestId('lotus-title').textContent(); + await page.getByRole('button', { name: '로고 Froxy' }).click(); + + // Then + await expect(page.getByText('Lotus는 gist 저장소들을 의미합니다')).toBeVisible(); + await page.waitForSelector('[data-testid="lotus-title"]'); + + const lotusTitles = await page.getByTestId('lotus-title').allTextContents(); + const isHidden = lotusTitles.every((value) => value !== targetTitle); + expect(isHidden).toBe(true); +}); diff --git a/apps/frontend/src/app/test/e2e/lotusList.spec.ts b/apps/frontend/src/app/test/e2e/lotusList.spec.ts new file mode 100644 index 00000000..99b7a026 --- /dev/null +++ b/apps/frontend/src/app/test/e2e/lotusList.spec.ts @@ -0,0 +1,49 @@ +import test, { expect } from '@playwright/test'; + +test('온보딩 페이지에서 "공개 프로젝트 보러가기" 버튼을 누르면 public Lotus 목록을 볼 수 있다.', async ({ page }) => { + // Give + await page.goto('/'); + + // When + await page.getByRole('button', { name: '공개 프로젝트 보러가기' }).click(); + await page.waitForSelector('[data-testid="lotus-title"]'); + const lotusComponents = await page.getByTestId('lotus-title').count(); + + // then + await expect(page.getByText('Lotus는 gist 저장소들을 의미합니다')).toBeVisible(); + expect(lotusComponents).toBeGreaterThan(0); + await expect(page.getByText('1', { exact: true })).toBeVisible(); +}); + +test('Lotus 목록 페이지에서 제목 검색이 가능해야 한다.', async ({ page }) => { + // Give + const searchText = 'test'; + await page.goto('/lotus'); + + // When + await page.getByPlaceholder('제목을 검색해주세요').click(); + await page.getByPlaceholder('제목을 검색해주세요').fill(searchText); + await page.getByRole('button', { name: '검색하기' }).click(); + + await page.waitForSelector('[data-testid="lotus-title"]', { timeout: 3000 }).catch(() => {}); + const lotusTitles = await page.getByTestId('lotus-title').allTextContents(); + + // then + const hasInvalidTitle = lotusTitles?.some((title) => !title.includes(searchText)); + expect(hasInvalidTitle).toBe(false); +}); + +test('Lotus 목록 페이지에서 Lotus 카드를 클릭하면 Lotus 상세 페이지로 이동한다.', async ({ page }) => { + // Given + await page.goto('/lotus'); + + // When + const lotusLink = page.getByTestId('lotus-link').first(); + const expectedUrl = await lotusLink.getAttribute('href'); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // Then + const currentUrl = page.url(); + expect(currentUrl).toBe('http://localhost:5173' + expectedUrl); +}); diff --git a/apps/frontend/src/app/test/e2e/user.spec.ts b/apps/frontend/src/app/test/e2e/user.spec.ts new file mode 100644 index 00000000..87b94f45 --- /dev/null +++ b/apps/frontend/src/app/test/e2e/user.spec.ts @@ -0,0 +1,126 @@ +import test, { expect } from '@playwright/test'; + +test('로그인을 안하면 헤더에서 로그인 버튼을 볼 수 있다.', async ({ page }) => { + // Given + await page.goto(`/lotus`); + + // When + const loginButton = page.getByRole('button', { name: 'Login' }); + await loginButton.waitFor({ state: 'visible' }); + + // Then + expect(await loginButton.isVisible()).toBe(true); +}); + +test('로그인 상태이면 헤더에서 Lotus 작성, 로그인, 프로필을 볼 수 있다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + const createLotusButton = page.getByRole('link', { name: 'Create Lotus' }); + const logoutButton = page.getByRole('button', { name: 'Logout' }); + const profile = page.getByTestId('header-profile'); + await profile.waitFor({ state: 'visible' }); + + // Then + expect(await page.getByText('mockUser님 환영합니다!').isVisible()).toBe(true); + expect(await createLotusButton.isVisible()).toBe(true); + expect(await logoutButton.isVisible()).toBe(true); + expect(await profile.isVisible()).toBe(true); +}); + +test('로그인 상태이면 헤더에서 Lotus를 생성하기를 통해 Lotus를 생성할 수 있다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + await page.getByRole('link', { name: 'Create Lotus' }).click(); + + await page.getByPlaceholder('제목을 입력해주세요').click(); + await page.getByPlaceholder('제목을 입력해주세요').fill('테스트 제목'); + await page.getByPlaceholder('태그를 입력해주세요').click(); + await page.getByPlaceholder('태그를 입력해주세요').fill('tag1'); + await page.getByPlaceholder('태그를 입력해주세요').press('Enter'); + await page.getByPlaceholder('태그를 입력해주세요').fill('tag2'); + await page.getByPlaceholder('태그를 입력해주세요').press('Enter'); + + await page.getByRole('combobox').click(); + await page.getByLabel('My First Gist').click(); + await page.getByRole('button', { name: '생성하기' }).click(); + + // Then + await expect(page.getByText('테스트 제목')).toBeVisible(); + await expect(page.getByText('tag1')).toBeVisible(); + await expect(page.getByText('tag2')).toBeVisible(); + await expect(page.getByText('Lotus가 생성되었습니다.')).toBeVisible(); +}); + +test('헤더에서 로그아웃 버튼을 누르면 메인 페이지로 이동한다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + await page.getByRole('button', { name: 'Logout' }).click(); + await page.waitForLoadState('load'); + + // Then + const currentUrl = page.url(); + expect(currentUrl).toBe('http://localhost:5173/'); +}); + +test('헤더에서 프로필 버튼을 누르면 사용자 정보 페이지로 이동한다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + await page.getByTestId('header-profile').click(); + await page.waitForLoadState('load'); + await page.waitForSelector('[data-testid="lotus-title"]'); + + // Then + const lotusComponents = await page.getByTestId('lotus-title').count(); + expect(lotusComponents).toBeGreaterThan(0); + await expect(page.getByTestId('user-profile')).toBeVisible(); + await expect(page.getByTestId('user-nickname')).toBeVisible(); +}); + +test('사용자 정보 페이지에서 자신의 닉네임을 수정할 수 있다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + await page.getByTestId('header-profile').click(); + await page.getByTestId('user-profile').waitFor({ state: 'visible' }); + await page.locator('.flex > svg').first().click(); + await page.getByPlaceholder('값을 입력해주세요').click(); + await page.getByPlaceholder('값을 입력해주세요').fill('new Nickname'); + await page.getByRole('button', { name: '수정하기' }).click(); + + // Then + await page.getByText('new Nickname').waitFor({ state: 'visible' }); + expect(await page.getByText('new Nickname').isVisible()).toBe(true); + expect(await page.getByText('닉네임이 수정되었습니다.').isVisible()).toBe(true); +}); + +test('사용자 정보 페이지에서 자신이 작성한 Lotus 카드를 클릭하면 Lotus 상세 페이지로 이동한다.', async ({ page }) => { + // Given + const MOCK_CODE = 'mock-code'; + await page.goto(`/login?code=${MOCK_CODE}`); + + // When + await page.getByTestId('header-profile').click(); + await page.waitForLoadState('load'); + const lotusLink = page.getByTestId('lotus-link').first(); + const expectedUrl = await lotusLink.getAttribute('href'); + await lotusLink.click(); + await page.waitForLoadState('load'); + + // Then + const currentUrl = page.url(); + expect(currentUrl).toBe('http://localhost:5173' + expectedUrl); +}); diff --git a/apps/frontend/src/feature/codeView/index.tsx b/apps/frontend/src/feature/codeView/index.tsx deleted file mode 100644 index 157de1b5..00000000 --- a/apps/frontend/src/feature/codeView/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { CodeSideBar } from './CodeSideBar'; -import { CodeViewer } from './CodeViewer'; -import { CodeViewProvider } from './CodeViewProvider'; - -import '@froxy/react-markdown/github.css'; - -export interface CodeViewValue { - filename: string; - language: string; - content: string; -} - -export const CodeView = Object.assign(CodeViewProvider, { SideBar: CodeSideBar, Viewer: CodeViewer }); diff --git a/apps/frontend/src/feature/codeView/model/index.ts b/apps/frontend/src/feature/codeView/model/index.ts new file mode 100644 index 00000000..9f8ccadd --- /dev/null +++ b/apps/frontend/src/feature/codeView/model/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/apps/frontend/src/feature/codeView/model/model.test.ts b/apps/frontend/src/feature/codeView/model/model.test.ts new file mode 100644 index 00000000..b95995b9 --- /dev/null +++ b/apps/frontend/src/feature/codeView/model/model.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { CodeFileModel } from './model'; + +describe('CodeFileModel', () => { + it.each([ + { + description: '올바른 DTO를 받아 CodeFileModel을 생성합니다.', + dto: { + filename: 'example.js', + language: 'JavaScript', + content: 'console.log("Hello, world!");' + }, + expected: { + filename: 'example.js', + language: 'JavaScript', + content: 'console.log("Hello, world!");', + ext: 'js' + } + }, + { + description: '비어있는 값은 빈 값으로 처리합니다.', + dto: { + filename: '', + language: '', + content: '' + }, + expected: { + filename: '', + language: '', + content: '', + ext: '' + } + } + ])('$description', ({ dto, expected }) => { + // Given + const model = new CodeFileModel(dto); + + //When + + // Then: 필드 검증 + expect(model).toMatchObject(expected); + }); + + it.each([ + { + description: 'README 파일일 경우 isREADME가 true입니다.', + dto: { + filename: 'README.md', + language: 'Markdown', + content: '# Hello World' + }, + expected: true + }, + { + description: 'README 파일이 아닐 경우 isREADME가 false입니다.', + dto: { + filename: 'example.js', + language: 'JavaScript', + content: 'console.log("Hello, world!");' + }, + expected: false + } + ])('$description', ({ dto, expected }) => { + // Given + const model = new CodeFileModel(dto); + + //When + + // Then: isREADME getter 확인 + expect(model.isREADME).toBe(expected); + }); + + it.each([ + { + description: '마크다운 파일인 경우 isMarkdown가 true입니다.', + dto: { + filename: 'example.md', + language: 'Markdown', + content: '# Hello World' + }, + expected: true + }, + { + description: '마크다운 파일이 아닌 경우 isMarkdown가 false입니다.', + dto: { + filename: 'example.js', + language: 'JavaScript', + content: 'console.log("Hello, world!");' + }, + expected: false + } + ])('$description', ({ dto, expected }) => { + // Given + const model = new CodeFileModel(dto); + + //When + + // Then: isMarkdown getter 확인 + expect(model.isMarkdown).toBe(expected); + }); + + it.each([ + { + description: '지원하는 확장자 형식인 경우 canView가 true입니다.', + dto: { + filename: 'example.md', + language: 'Markdown', + content: '# Hello World' + }, + expected: true + }, + { + description: '지원하지 않는 확장자의 경우 canView가 false입니다.', + dto: { + filename: 'example.png', + language: 'Binary', + content: '' + }, + expected: false + } + ])('$description', ({ dto, expected }) => { + // Given + const model = new CodeFileModel(dto); + + //When + + // Then: canView getter 확인 + expect(model.canView).toBe(expected); + }); + + it.each([ + { + description: 'README 파일이 존재하는 경우 getDefaultFile은 README 파일을 반환합니다.', + files: [ + new CodeFileModel({ filename: 'README.md', language: 'Markdown', content: '# Hello' }), + new CodeFileModel({ filename: 'example.js', language: 'JavaScript', content: 'console.log("test");' }), + new CodeFileModel({ filename: 'example.md', language: 'Markdown', content: 'hola' }) + ], + expected: 'README.md' + }, + { + description: 'README 파일이 없고 마크다운 파일이 존재하는 경우 getDefaultFile은 마크다운 파일을 반환합니다.', + files: [ + new CodeFileModel({ filename: 'example.md', language: 'Markdown', content: '# Hello' }), + new CodeFileModel({ filename: 'example.js', language: 'JavaScript', content: 'console.log("test");' }) + ], + expected: 'example.md' + }, + { + description: 'README 파일과 마크다운 파일이 없는 경우 getDefaultFile은 undefined를 반환합니다.', + files: [ + new CodeFileModel({ filename: 'example.js', language: 'JavaScript', content: 'console.log("test");' }), + new CodeFileModel({ filename: 'example.py', language: 'Python', content: 'print("Hello")' }) + ], + expected: undefined + } + ])('$description', ({ files, expected }) => { + //Given + + //When + const defaultFile = CodeFileModel.getDefaultFile(files); + + //Then + expect(defaultFile?.filename).toBe(expected); + }); + + it.each([ + { + description: '지원하는 언어파일인 경우 언어에 맞는 content를 마크다운 문자열로 반환합니다', + dto: { + filename: 'example.js', + language: 'JavaScript', + content: 'console.log("Hello, world!");' + }, + expected: '```js\nconsole.log("Hello, world!");\n ```' + }, + { + description: '지원하지 않는 언어 파일이나, 언어 파일이 아닌 경우 그냥 content를 반환합니다', + dto: { + filename: 'example.txt', + language: 'Text', + content: 'Just some text' + }, + expected: 'Just some text' + } + ])('$description', ({ dto, expected }) => { + // Given + const model = new CodeFileModel(dto); + + // Then: toMarkdown 확인 + expect(model.toMarkdown()).toBe(expected); + }); +}); diff --git a/apps/frontend/src/feature/codeView/model.ts b/apps/frontend/src/feature/codeView/model/model.ts similarity index 93% rename from apps/frontend/src/feature/codeView/model.ts rename to apps/frontend/src/feature/codeView/model/model.ts index e15d6bd6..063dbea6 100644 --- a/apps/frontend/src/feature/codeView/model.ts +++ b/apps/frontend/src/feature/codeView/model/model.ts @@ -1,4 +1,4 @@ -import { CANT_VIEW_EXT, LANGUAGES_EXT } from './constant'; +import { CANT_VIEW_EXT, LANGUAGES_EXT } from '@/feature/codeView/constant'; export interface CodeFileDto { filename: string; diff --git a/apps/frontend/src/feature/codeView/type.ts b/apps/frontend/src/feature/codeView/type.ts new file mode 100644 index 00000000..0f985b88 --- /dev/null +++ b/apps/frontend/src/feature/codeView/type.ts @@ -0,0 +1,5 @@ +export interface CodeViewValue { + filename: string; + language: string; + content: string; +} diff --git a/apps/frontend/src/feature/comment/index.tsx b/apps/frontend/src/feature/comment/index.tsx deleted file mode 100644 index 0c853c86..00000000 --- a/apps/frontend/src/feature/comment/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from './Comment'; -export * from './CommentForm'; -export * from './CommentCard'; diff --git a/apps/frontend/src/feature/history/component.tsx b/apps/frontend/src/feature/history/component.tsx index 377d7bf8..a7e34bd1 100644 --- a/apps/frontend/src/feature/history/component.tsx +++ b/apps/frontend/src/feature/history/component.tsx @@ -61,7 +61,7 @@ export function HistoryStatusLabel({ className, ...props }: HistoryStatusLabelPr const { status } = useHistoryContext(); return ( - + {status.toLocaleLowerCase()} ); diff --git a/apps/frontend/src/feature/history/model/index.ts b/apps/frontend/src/feature/history/model/index.ts new file mode 100644 index 00000000..9f8ccadd --- /dev/null +++ b/apps/frontend/src/feature/history/model/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/apps/frontend/src/feature/history/model/model.test.ts b/apps/frontend/src/feature/history/model/model.test.ts new file mode 100644 index 00000000..49b1b50c --- /dev/null +++ b/apps/frontend/src/feature/history/model/model.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { HistoryModel } from './model'; +import { HISTORY_STATUS } from '@/feature/history/constant'; + +describe('HistoryModel', () => { + it.each([ + { + description: '올바른 DTO를 받아 HistoryModel을 생성합니다.', + dto: { + id: '1', + status: HISTORY_STATUS.SUCCESS, + date: '2024-01-01T00:00:00.000Z', + input: 'some input', + output: 'some output', + filename: 'file1.txt' + }, + expected: { + id: '1', + status: HISTORY_STATUS.SUCCESS, + date: new Date('2024-01-01T00:00:00.000Z'), + input: 'some input', + output: 'some output', + filename: 'file1.txt' + } + }, + { + description: '잘못된 status를 받아 HistoryModel을 생성하면 status는 ERROR로 설정됩니다.', + dto: { + id: '2', + status: 'INVALID_STATUS', + date: '2024-02-01T00:00:00.000Z', + input: 'input2', + output: 'output2', + filename: 'file2.txt' + }, + expected: { + id: '2', + status: HISTORY_STATUS.ERROR, + date: new Date('2024-02-01T00:00:00.000Z'), + input: 'input2', + output: 'output2', + filename: 'file2.txt' + } + } + ])('$description', ({ dto, expected }) => { + // Given + const historyModel = new HistoryModel(dto); + + // Then: 객체 속성 검증 + expect(historyModel).toMatchObject(expected); + }); + + it('getPendingHistoriesId는 HistoryModel[]을 인자로 받아 PENDING 상태인 HistoryId를 반환합니다.', () => { + // Given + const expected = ['1', '3']; + const histories = [ + new HistoryModel({ + id: '1', + status: HISTORY_STATUS.PENDING, + date: '2024-01-01T00:00:00.000Z', + input: 'input1', + output: 'output1', + filename: 'file1.txt' + }), + new HistoryModel({ + id: '2', + status: HISTORY_STATUS.SUCCESS, + date: '2024-02-01T00:00:00.000Z', + input: 'input2', + output: 'output2', + filename: 'file2.txt' + }), + new HistoryModel({ + id: '3', + status: HISTORY_STATUS.PENDING, + date: '2024-03-01T00:00:00.000Z', + input: 'input3', + output: 'output3', + filename: 'file3.txt' + }) + ]; + + // When + const pendingIds = HistoryModel.getPendingHistoriesId(histories); + + // Then + expect(pendingIds).toEqual(expected); + }); + + it.each([ + { + history: new HistoryModel({ + id: '1', + status: HISTORY_STATUS.SUCCESS, + date: '2024-01-01T00:00:00.000Z', + input: 'input1', + output: 'output1', + filename: 'file1.txt' + }), + status: HISTORY_STATUS.SUCCESS, + expected: true + }, + { + history: new HistoryModel({ + id: '2', + status: HISTORY_STATUS.ERROR, + date: '2024-02-01T00:00:00.000Z', + input: 'input2', + output: 'output2', + filename: 'file2.txt' + }), + status: HISTORY_STATUS.PENDING, + expected: false + } + ])( + 'isStatus를 통해 history의 status를 검증할 수 있습니다. ($history.status 일때 history.isStatus($status)는 $expected 입니다.)', + ({ history, status, expected }) => { + //Given + //When + + //Then + expect(history.isStatus(status)).toBe(expected); + } + ); +}); diff --git a/apps/frontend/src/feature/history/model.ts b/apps/frontend/src/feature/history/model/model.ts similarity index 89% rename from apps/frontend/src/feature/history/model.ts rename to apps/frontend/src/feature/history/model/model.ts index 14689f1a..f8b16081 100644 --- a/apps/frontend/src/feature/history/model.ts +++ b/apps/frontend/src/feature/history/model/model.ts @@ -1,5 +1,5 @@ -import { HISTORY_STATUS } from './constant'; -import { HistoryStatus } from './type'; +import { HISTORY_STATUS } from '@/feature/history/constant'; +import { HistoryStatus } from '@/feature/history/type'; export interface HistoryDto { id: string; diff --git a/apps/frontend/src/feature/lotus/component.tsx b/apps/frontend/src/feature/lotus/component.tsx index c9eb424f..f61343d5 100644 --- a/apps/frontend/src/feature/lotus/component.tsx +++ b/apps/frontend/src/feature/lotus/component.tsx @@ -1,5 +1,5 @@ import { ComponentProps, HTMLProps, ReactNode, createContext, useContext } from 'react'; -import { Badge, Text } from '@froxy/design/components'; +import { Badge, Button, Text } from '@froxy/design/components'; import { cn } from '@froxy/design/utils'; import { Link } from '@tanstack/react-router'; import { LotusModel } from '.'; @@ -22,7 +22,7 @@ export function LotusTitle(props: LotusTitleProps) { const { title } = useLotusContext(); return ( - + {title} ); @@ -63,7 +63,7 @@ export function LotusLink({ children, className }: LotusLinkProps) { const { id: lotusId } = useLotusContext(); return ( - + {children} ); @@ -74,13 +74,20 @@ type LotusGistLinkProps = { children?: ReactNode; }; +// a태그 내부에서 사용될 가능성이 높아 Button 컴포넌트로 사용 export function LotusGistLink({ className, children }: LotusGistLinkProps) { const { gistUrl } = useLotusContext(); + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + + location.href = gistUrl; + }; + return ( - + ); } diff --git a/apps/frontend/src/feature/lotus/model/index.ts b/apps/frontend/src/feature/lotus/model/index.ts new file mode 100644 index 00000000..9f8ccadd --- /dev/null +++ b/apps/frontend/src/feature/lotus/model/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/apps/frontend/src/feature/lotus/model/model.test.ts b/apps/frontend/src/feature/lotus/model/model.test.ts new file mode 100644 index 00000000..0a2a0e81 --- /dev/null +++ b/apps/frontend/src/feature/lotus/model/model.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { LotusDto, LotusModel } from './model'; + +describe('LotusModel', () => { + it.each([ + { + description: '올바른 LotusDto로 LotusModel 생성할 수 있다.', + lotusDto: { + id: '1', + link: 'https://example.com', + title: 'Example Title', + logo: 'https://example.com/logo.png', + date: '2024-01-01T00:00:00.000Z', + tags: ['tag1', 'tag2'], + isPublic: true, + gistUrl: 'https://gist.github.com/example' + }, + expected: { + id: '1', + link: 'https://example.com', + title: 'Example Title', + logo: 'https://example.com/logo.png', + date: new Date('2024-01-01T00:00:00.000Z'), + tags: ['tag1', 'tag2'], + isPublic: true, + gistUrl: 'https://gist.github.com/example', + isTagsEmpty: false + } + }, + { + description: '태그가 없는 LotusDto로 LotusModel 생성할 수 있다.', + lotusDto: { + id: '2', + link: 'https://example.com/2', + title: 'Another Title', + logo: 'https://example.com/logo2.png', + date: '2024-02-01T00:00:00.000Z', + tags: [], + gistUrl: 'https://gist.github.com/example2' + }, + expected: { + id: '2', + link: 'https://example.com/2', + title: 'Another Title', + logo: 'https://example.com/logo2.png', + date: new Date('2024-02-01T00:00:00.000Z'), + tags: [], + isPublic: undefined, + gistUrl: 'https://gist.github.com/example2', + isTagsEmpty: true + } + } + ])('$description', ({ lotusDto, expected }) => { + //Given + const lotusModel = new LotusModel(lotusDto); + + //When + + //Then + expect(lotusModel).toMatchObject(expected); + expect(lotusModel.isTagsEmpty).toBe(expected.isTagsEmpty); + }); + + it('clone메서드를 통해 새로운 LotusModel 객체를 만들 수 있다.', () => { + //Given + const originalDto: LotusDto = { + id: '3', + link: 'https://example.com/3', + title: 'Original Title', + logo: 'https://example.com/logo3.png', + date: '2024-03-01T00:00:00.000Z', + tags: ['original'], + isPublic: false, + gistUrl: 'https://gist.github.com/original' + }; + const originalModel = new LotusModel(originalDto); + const cloneDto = { + title: 'Updated Title', + tags: ['updated'], + date: new Date('2024-04-01T00:00:00.000Z') + }; + + // When + const clonedModel = originalModel.clone(cloneDto); + + // Then + expect(new LotusModel(originalDto)).toMatchObject(originalModel); + + expect(clonedModel).toMatchObject( + new LotusModel({ ...originalDto, ...cloneDto, date: cloneDto.date.toISOString() }) + ); + }); +}); diff --git a/apps/frontend/src/feature/lotus/model.ts b/apps/frontend/src/feature/lotus/model/model.ts similarity index 100% rename from apps/frontend/src/feature/lotus/model.ts rename to apps/frontend/src/feature/lotus/model/model.ts diff --git a/apps/frontend/src/feature/pagination/index.ts b/apps/frontend/src/feature/pagination/index.ts index 601b0749..31566ded 100644 --- a/apps/frontend/src/feature/pagination/index.ts +++ b/apps/frontend/src/feature/pagination/index.ts @@ -1,3 +1,3 @@ export * from './usePagination'; export * from './Pagination'; -export * from './type'; +export * from './model'; diff --git a/apps/frontend/src/feature/pagination/type.ts b/apps/frontend/src/feature/pagination/model.ts similarity index 100% rename from apps/frontend/src/feature/pagination/type.ts rename to apps/frontend/src/feature/pagination/model.ts diff --git a/apps/frontend/src/feature/user/model/index.ts b/apps/frontend/src/feature/user/model/index.ts new file mode 100644 index 00000000..9f8ccadd --- /dev/null +++ b/apps/frontend/src/feature/user/model/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/apps/frontend/src/feature/user/model/model.test.ts b/apps/frontend/src/feature/user/model/model.test.ts new file mode 100644 index 00000000..187e6dc9 --- /dev/null +++ b/apps/frontend/src/feature/user/model/model.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { UserModel } from './model'; // 경로는 실제 파일 경로로 변경 + +describe('UserModel', () => { + it.each([ + { + description: '올바른 UserDto를 전달받으면 UserModel을 생성한다', + userDto: { + id: '123', + nickname: 'testUser', + profile: 'https://example.com/profile.png', + gistUrl: 'https://gist.github.com/testUser' + }, + expected: { + id: '123', + nickname: 'testUser', + profile: 'https://example.com/profile.png', + gistUrl: 'https://gist.github.com/testUser' + } + }, + { + description: 'profile이 비어있는 UserDto를 전달받으면 기본 이미지를 사용하는 UserModel을 생성한다', + userDto: { + id: '456', + nickname: 'defaultProfileUser', + profile: '', // 비어있는 값 + gistUrl: 'https://gist.github.com/defaultProfileUser' + }, + expected: { + id: '456', + nickname: 'defaultProfileUser', + profile: '/image/logoIcon.svg', // 기본값 + gistUrl: 'https://gist.github.com/defaultProfileUser' + } + } + ])('$description', ({ userDto, expected }) => { + //Given + const userModel = new UserModel(userDto); + + //When + + //Then + expect(userModel).toEqual(expected); + }); +}); diff --git a/apps/frontend/src/feature/user/model.ts b/apps/frontend/src/feature/user/model/model.ts similarity index 100% rename from apps/frontend/src/feature/user/model.ts rename to apps/frontend/src/feature/user/model/model.ts diff --git a/apps/frontend/src/feature/user/type.ts b/apps/frontend/src/feature/user/type.ts new file mode 100644 index 00000000..e3aa9ae1 --- /dev/null +++ b/apps/frontend/src/feature/user/type.ts @@ -0,0 +1,5 @@ +export interface UserType { + id: string; + nickname: string; + profile: string; +} diff --git a/apps/frontend/src/page/(main)/lotus/$lotusId/index.lazy.tsx b/apps/frontend/src/page/(main)/lotus/$lotusId/index.lazy.tsx index 04da2384..40fd9c86 100644 --- a/apps/frontend/src/page/(main)/lotus/$lotusId/index.lazy.tsx +++ b/apps/frontend/src/page/(main)/lotus/$lotusId/index.lazy.tsx @@ -60,6 +60,7 @@ function RouteComponent() { diff --git a/apps/frontend/src/shared/.gitkeep b/apps/frontend/src/shared/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/widget/.gitkeep b/apps/frontend/src/widget/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/widget/Header.tsx b/apps/frontend/src/widget/Header.tsx index e2d9e49b..98b484a7 100644 --- a/apps/frontend/src/widget/Header.tsx +++ b/apps/frontend/src/widget/Header.tsx @@ -34,7 +34,7 @@ export function Header() { - + 프로필 사진 diff --git a/apps/frontend/src/widget/lotusCodeRun/CodeRunButton.tsx b/apps/frontend/src/widget/lotusCodeRun/CodeRunButton.tsx index dea6a44d..73f3d8bf 100644 --- a/apps/frontend/src/widget/lotusCodeRun/CodeRunButton.tsx +++ b/apps/frontend/src/widget/lotusCodeRun/CodeRunButton.tsx @@ -1,5 +1,6 @@ import { Button } from '@froxy/design/components'; import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { LotusRunCodeForm } from './LotusRunCodeForm'; import { lotusHistoryQueryOptions, useCodeRunMutation } from '@/feature/history/query'; import { ModalBox } from '@/shared'; @@ -7,6 +8,8 @@ import { useOverlay } from '@/shared/overlay'; import { useToast } from '@/shared/toast'; export function CodeRunButton({ lotusId }: { lotusId: string }) { + const navigate = useNavigate(); + const { open, exit } = useOverlay(); const { toast } = useToast(); @@ -25,6 +28,7 @@ export function CodeRunButton({ lotusId }: { lotusId: string }) { toast({ description: '코드가 실행되었습니다.', variant: 'success', duration: 2000 }); queryClient.invalidateQueries(lotusHistoryQueryOptions.list({ id: lotusId })); + navigate({ to: '/lotus/$lotusId', params: { lotusId } }); }, onError: () => { toast({ diff --git a/apps/frontend/src/widget/lotusCodeRun/LotusRunCodeForm.tsx b/apps/frontend/src/widget/lotusCodeRun/LotusRunCodeForm.tsx index 0b2a692d..c6256640 100644 --- a/apps/frontend/src/widget/lotusCodeRun/LotusRunCodeForm.tsx +++ b/apps/frontend/src/widget/lotusCodeRun/LotusRunCodeForm.tsx @@ -45,7 +45,7 @@ export function LotusRunCodeForm({ lotusId, onCancel, onSubmit }: LotusRunCodeFo 취소하기 diff --git a/apps/frontend/src/widget/lotusDelete/index.tsx b/apps/frontend/src/widget/lotusDelete/index.tsx index 476e0db8..8c083606 100644 --- a/apps/frontend/src/widget/lotusDelete/index.tsx +++ b/apps/frontend/src/widget/lotusDelete/index.tsx @@ -1,7 +1,8 @@ import { Button, Heading, Text } from '@froxy/design/components'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { RiDeleteBin5Fill } from 'react-icons/ri'; -import { getLotusMutationErrorToastData, useLotusDeleteMutation } from '@/feature/lotus'; +import { getLotusMutationErrorToastData, lotusQueryOptions, useLotusDeleteMutation } from '@/feature/lotus'; import { ModalBox } from '@/shared'; import { useOverlay } from '@/shared/overlay'; import { useToast } from '@/shared/toast'; @@ -9,6 +10,8 @@ import { useToast } from '@/shared/toast'; export function LotusDeleteButton({ lotusId }: { lotusId: string }) { const { mutate, isPending } = useLotusDeleteMutation(); + const queryClient = useQueryClient(); + const { open, exit } = useOverlay(); const { toast } = useToast({ isCloseOnUnmount: false }); @@ -22,7 +25,7 @@ export function LotusDeleteButton({ lotusId }: { lotusId: string }) { { onSuccess: () => { toast({ description: 'Lotus가 삭제되었습니다.', variant: 'success', duration: 2000 }); - + queryClient.invalidateQueries(lotusQueryOptions.list({})); navigate({ to: '/lotus' }); }, onError: (error) => { diff --git a/apps/frontend/src/widget/lotusList/SuspenseLotusCardList.tsx b/apps/frontend/src/widget/lotusList/SuspenseLotusList.tsx similarity index 69% rename from apps/frontend/src/widget/lotusList/SuspenseLotusCardList.tsx rename to apps/frontend/src/widget/lotusList/SuspenseLotusList.tsx index be2d52e2..1f683755 100644 --- a/apps/frontend/src/widget/lotusList/SuspenseLotusCardList.tsx +++ b/apps/frontend/src/widget/lotusList/SuspenseLotusList.tsx @@ -1,4 +1,4 @@ -import { Button, Heading, Skeleton } from '@froxy/design/components'; +import { Button, Heading, Skeleton, Text } from '@froxy/design/components'; import { DotLottieReact } from '@lottiefiles/dotlottie-react'; import { useQueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'; import axios from 'axios'; @@ -13,21 +13,23 @@ export function SuspenseLotusList({ queryOptions }: { queryOptions: LotusLostQue data: { lotuses } } = useSuspenseQuery(queryOptions); + if (lotuses.length < 1) return ; + return ( -
+
{lotuses?.map(({ lotus, author }) => ( -
-
- + +
+
- - - - -
- + + + + +
+
@@ -39,8 +41,8 @@ export function SuspenseLotusList({ queryOptions }: { queryOptions: LotusLostQue )} - -
+
+ ))} @@ -48,7 +50,19 @@ export function SuspenseLotusList({ queryOptions }: { queryOptions: LotusLostQue ); } -function SkeletonLotusCardList() { +function SuspenseLotusListEmpty() { + return ( +
+ + + 작성된 Lotus가 없습니다. + + Lotus를 작성해보세요! +
+ ); +} + +function SkeletonLotusList() { return (
{range(5).map((value) => ( @@ -69,7 +83,7 @@ function SkeletonLotusCardList() { ); } -SuspenseLotusList.Skeleton = SkeletonLotusCardList; +SuspenseLotusList.Skeleton = SkeletonLotusList; interface ErrorProps { error: unknown; @@ -77,7 +91,7 @@ interface ErrorProps { onChangePage: (page?: number) => Promise; } -function ErrorLotusCardList({ error, retry, onChangePage }: ErrorProps) { +function ErrorLotusList({ error, retry, onChangePage }: ErrorProps) { const { reset } = useQueryErrorResetBoundary(); if (axios.isAxiosError(error) && error?.status === 401) throw error; @@ -100,4 +114,4 @@ function ErrorLotusCardList({ error, retry, onChangePage }: ErrorProps) { ); } -SuspenseLotusList.Error = ErrorLotusCardList; +SuspenseLotusList.Error = ErrorLotusList; diff --git a/apps/frontend/src/widget/lotusList/index.ts b/apps/frontend/src/widget/lotusList/index.ts index 1aa32679..941bda33 100644 --- a/apps/frontend/src/widget/lotusList/index.ts +++ b/apps/frontend/src/widget/lotusList/index.ts @@ -1,2 +1,2 @@ -export * from './SuspenseLotusCardList'; +export * from './SuspenseLotusList'; export * from './LotusSearchBar'; diff --git a/apps/frontend/src/widget/lotusList/index.tsx b/apps/frontend/src/widget/lotusList/index.tsx deleted file mode 100644 index 1aa32679..00000000 --- a/apps/frontend/src/widget/lotusList/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SuspenseLotusCardList'; -export * from './LotusSearchBar'; diff --git a/apps/frontend/src/widget/navigation/index.tsx b/apps/frontend/src/widget/navigation/index.tsx new file mode 100644 index 00000000..7d4eecd3 --- /dev/null +++ b/apps/frontend/src/widget/navigation/index.tsx @@ -0,0 +1,2 @@ +export * from './CreateLotusButton'; +export * from './LogoutButton'; diff --git a/apps/frontend/src/widget/user/SuspenseUserInfoBox.tsx b/apps/frontend/src/widget/user/SuspenseUserInfoBox.tsx index 8d0838f4..668ce437 100644 --- a/apps/frontend/src/widget/user/SuspenseUserInfoBox.tsx +++ b/apps/frontend/src/widget/user/SuspenseUserInfoBox.tsx @@ -4,6 +4,7 @@ import { Button, Heading, Skeleton } from '@froxy/design/components'; import { DotLottieReact } from '@lottiefiles/dotlottie-react'; import { useQueryClient, useQueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'; import axios from 'axios'; +import { FaGithub } from 'react-icons/fa'; import { GoPencil } from 'react-icons/go'; import { UserInfoInputForm } from '@/feature/user/component'; import { useUserMutation, userQueryOptions } from '@/feature/user/query'; @@ -47,38 +48,33 @@ export function SuspenseUserInfoBox() { }; return ( -
- {/* TODO: 나중에 프로필 사진 부분 하나의 feature로 합치기 */} - 프로필 사진 -
-
- - NICKNAME - -
- {isEdit ? ( - onEditUserInfo(value)} - /> - ) : ( -
- - {user.nickname} - - setIsEdit(true)} /> -
- )} -
- - GIST ADDRESS - - - {user.gistUrl} - -
+
+
+ 프로필 사진 + + + +
+
+ {isEdit ? ( + onEditUserInfo(value)} + /> + ) : ( + <> +
+ + {user.nickname} + + setIsEdit(true)} /> + + )}
); @@ -86,15 +82,15 @@ export function SuspenseUserInfoBox() { function SkeletonUserInfoBox() { return ( -
- -
-
- - - - -
+
+
+ + +
+
+
+ +
); diff --git a/mermaild.md b/mermaild.md new file mode 100644 index 00000000..f033e352 --- /dev/null +++ b/mermaild.md @@ -0,0 +1,12 @@ +stateDiagram-v2 + direction LR + 사용자request --> Pool: 코드실행 요청 + + state "Pool" as Pool + Pool --> Allocated: 컨테이너 할당 + Allocated --> Started: 컨테이너 Start + Started --> Running: 작업 실행 중 + Running --> Finish: 작업 완료 + Finish --> Exited: 컨테이너 stop + Finish --> 사용자request: 코드실행결과 반환 + Exited --> Pool: 컨테이너 Stop 후 반납 \ No newline at end of file diff --git a/npx b/npx new file mode 100644 index 00000000..5a312b43 --- /dev/null +++ b/npx @@ -0,0 +1,79 @@ +Test run id: t6neg_99a5dhhx98qw3gywhgwh56k4chpnx_87ef +Phase started: unnamed (index: 0, duration: 1s) 22:13:38(+0900) + +Phase completed: unnamed (index: 0, duration: 1s) 22:13:39(+0900) + +-------------------------------------- +Metrics for period to: 22:13:40(+0900) (width: 1s) +-------------------------------------- + +http.request_rate: ............................................................. 100/sec +http.requests: ................................................................. 100 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 + + +-------------------------------------- +Metrics for period to: 22:13:50(+0900) (width: 1.503s) +-------------------------------------- + +errors.ETIMEDOUT: .............................................................. 95 +http.codes.200: ................................................................ 5 +http.downloaded_bytes: ......................................................... 550 +http.request_rate: ............................................................. 5/sec +http.requests: ................................................................. 5 +http.response_time: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.response_time.2xx: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.responses: ................................................................ 5 +vusers.failed: ................................................................. 95 + + +-------------------------------------- +Metrics for period to: 22:14:00(+0900) (width: 0.241s) +-------------------------------------- + +errors.ETIMEDOUT: .............................................................. 5 +vusers.failed: ................................................................. 5 + + +All VUs finished. Total time: 21 seconds + +-------------------------------- +Summary report @ 22:14:00(+0900) +-------------------------------- + +errors.ETIMEDOUT: .............................................................. 100 +http.codes.200: ................................................................ 5 +http.downloaded_bytes: ......................................................... 550 +http.request_rate: ............................................................. 35/sec +http.requests: ................................................................. 105 +http.response_time: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.response_time.2xx: + min: ......................................................................... 9408 + max: ......................................................................... 9691 + mean: ........................................................................ 9510.4 + median: ...................................................................... 9416.8 + p95: ......................................................................... 9607.1 + p99: ......................................................................... 9607.1 +http.responses: ................................................................ 5 +vusers.created: ................................................................ 100 +vusers.created_by_name.0: ...................................................... 100 +vusers.failed: ................................................................. 100 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac853589..64509a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@eslint/js': specifier: ^9.13.0 version: 9.13.0 + '@playwright/test': + specifier: ^1.49.0 + version: 1.49.0 '@tanstack/react-query-devtools': specifier: ^5.59.19 version: 5.59.19(@tanstack/react-query@5.59.19(react@18.3.1))(react@18.3.1) @@ -214,6 +217,9 @@ importers: '@testing-library/react': specifier: ^16.0.1 version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.3.1 + version: 20.17.5 '@types/react': specifier: ^18.3.12 version: 18.3.12 @@ -313,6 +319,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + embla-carousel-autoplay: + specifier: ^8.5.1 + version: 8.5.1(embla-carousel@8.3.1) embla-carousel-react: specifier: ^8.3.1 version: 8.3.1(react@18.3.1) @@ -1488,6 +1497,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.49.0': + resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -3383,6 +3397,11 @@ packages: electron-to-chromium@1.5.50: resolution: {integrity: sha512-eMVObiUQ2LdgeO1F/ySTXsvqvxb6ZH2zPGaMYsWzRDdOddUa77tdmI0ltg+L16UpbWdhPmuF3wIQYyQq65WfZw==} + embla-carousel-autoplay@8.5.1: + resolution: {integrity: sha512-FnZklFpePfp8wbj177UwVaGFehgs+ASVcJvYLWTtHuYKURynCc3IdDn2qrn0E5Qpa3g9yeGwCS4p8QkrZmO8xg==} + peerDependencies: + embla-carousel: 8.5.1 + embla-carousel-react@8.3.1: resolution: {integrity: sha512-gBY0zM+2ASvKFwRpTIOn2SLifFqOKKap9R/y0iCpJWS3bc8OHVEn2gAThGYl2uq0N+hu9aBiswffL++OYZOmDQ==} peerDependencies: @@ -3833,6 +3852,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5304,6 +5328,16 @@ packages: pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + playwright-core@1.49.0: + resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.0: + resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7877,6 +7911,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.49.0': + dependencies: + playwright: 1.49.0 + '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.1.0': {} @@ -9883,6 +9921,10 @@ snapshots: electron-to-chromium@1.5.50: {} + embla-carousel-autoplay@8.5.1(embla-carousel@8.3.1): + dependencies: + embla-carousel: 8.3.1 + embla-carousel-react@8.3.1(react@18.3.1): dependencies: embla-carousel: 8.3.1 @@ -10575,6 +10617,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12466,6 +12511,14 @@ snapshots: mlly: 1.7.2 pathe: 1.1.2 + playwright-core@1.49.0: {} + + playwright@1.49.0: + dependencies: + playwright-core: 1.49.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.0.0: {}