diff --git a/.github/workflows/backend-deploy.yml b/.github/workflows/backend-deploy.yml index da95be6..c74cc86 100644 --- a/.github/workflows/backend-deploy.yml +++ b/.github/workflows/backend-deploy.yml @@ -74,5 +74,12 @@ jobs: -e CONTAINER_SSH_PORT=${{ secrets.CONTAINER_SSH_PORT }} \ -e CONTAINER_SSH_USERNAME=${{ secrets.CONTAINER_SSH_USERNAME }} \ -e CONTAINER_SSH_PASSWORD=${{ secrets.CONTAINER_SSH_PASSWORD }} \ + -e CONTAINER_GIT_USERNAME=${{ secrets.CONTAINER_GIT_USERNAME }} \ -e MONGODB_HOST=${{ secrets.MONGODB_HOST }} \ + -e SECRET_KEY=${{ secrets.SECRET_KEY }} \ + -e X_NCP_CLOVASTUDIO_API_KEY=${{ secrets.X_NCP_CLOVASTUDIO_API_KEY }} \ + -e X_NCP_APIGW_API_KEY=${{ secrets.X_NCP_APIGW_API_KEY }} \ + -e X_NCP_CLOVASTUDIO_REQUEST_ID=${{ secrets.X_NCP_CLOVASTUDIO_REQUEST_ID }} \ + -e CONTAINER_SERVER_HOST=${{ secrets.CONTAINER_SERVER_HOST }} \ + -e CONTAINER_POOL_MAX=${{ secrets.CONTAINER_POOL_MAX }} \ ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-backend:0.1 diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index 061a443..757fad5 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -3,7 +3,7 @@ name: "frontend-docker-build" on: push: branches: [ "dev-fe" ] - + jobs: build: name: Build and Test @@ -25,7 +25,7 @@ jobs: run: yarn install - name: Build - run: | + run: | cd packages/frontend yarn build @@ -53,6 +53,8 @@ jobs: file: ./packages/frontend/Dockerfile push: true tags: ${{ secrets.DOCKERHUB_USERNAME }}/git-challenge-frontend:0.1 + build-args: | + NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }} deploy: name: Deploy Frontend diff --git a/packages/backend/git-challenge-quiz.csv b/packages/backend/git-challenge-quiz.csv index e5c63b2..c68d945 100644 --- a/packages/backend/git-challenge-quiz.csv +++ b/packages/backend/git-challenge-quiz.csv @@ -1,38 +1,366 @@ -id,title,description,category,keyword -1,git init,현재 디렉터리를 새로운 Git 저장소로 만들어주세요.,Git Start,init -2,git config,현재 디렉터리의 Git 저장소 환경에서 user name과 user email을 여러분의 name과 email로 설정해주세요.,Git Start,config -3,git add & git status,현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.,Git Start,"status, add" -4,git commit,현재 디렉터리 내의 모든 파일을 commit 해주세요.,Git Start,commit -5,git branch & git switch & git checkout,`dev`라는 이름의 branch를 생성해주세요.,Git Start,"branch, switch, checkout" -6,git switch,현재 상황을 commit하고 `main` branch로 돌아가주세요.,Git Start,switch -7,git commit --amend,"당신은 프로젝트에서 회원가입, 로그인, 회원탈퇴 기능을 담당하게 되었습니다. +id,title,description,category,keyword,answer,graph,ref +1,git 시작하기,현재 디렉터리를 새로운 Git 저장소로 만들어주세요.,Git Start,init,`git` init,"{ + ""graph"": [] +}", +2,내 정보 설정하기,현재 디렉터리의 Git 저장소 환경에서 user name과 user email을 여러분의 name과 email로 설정해주세요.,Git Start,config,"`git` config user.name ""유저 이름""\n`git` config user.email 유저 이메일","{ + ""graph"": [] +}",main +3,파일 스테이징 하기,현재 변경된 파일 중에서 `architecture.md` 파일을 제외하고 staging 해주세요.,Git Start,"status, add",`git` status\n`git` add README.md docs/plan.md,"{ + ""graph"": [ + { + ""id"": ""410b79bff71d5e6ab7ae2d9a94e204c0f83a961e"", + ""parentId"": """", + ""message"": ""docs: plan.md"", + ""refs"": ""HEAD -> main"" + } + ] +}",main +4,커밋하기,현재 디렉터리 내의 모든 파일을 commit 해주세요.,Git Start,commit,`git` add .\n`git` commit,"{ + ""graph"": [ + { + ""id"": ""0b6bb091c739e7aec2cb724378d50e486a914768"", + ""parentId"": """", + ""message"": ""docs: plan.md"", + ""refs"": ""HEAD -> main"" + } + ] +}",main +5,브랜치 만들기,`dev`라는 이름의 branch를 생성해주세요.,Git Start,"branch, switch, checkout",`git` branch dev\n`git` switch -c dev\n`git` checkout -b dev,"{ + ""graph"": [ + { + ""id"": ""68a11739add85f3150573194c802e93aa1361d11"", + ""parentId"": ""0b6bb091c739e7aec2cb724378d50e486a914768"", + ""message"": ""docs: plan.md"", + ""refs"": ""HEAD -> main"" + }, + { + ""id"": ""0b6bb091c739e7aec2cb724378d50e486a914768"", + ""parentId"": """", + ""message"": ""docs: plan.md"", + ""refs"": """" + } + ] +}",main +6,브랜치 바꾸기,현재 상황을 commit하고 `main` branch로 돌아가주세요.,Git Start,switch,`git` add .\n`git` commit\n`git` switch main,"{ + ""graph"": [ + { + ""id"": ""68a11739add85f3150573194c802e93aa1361d11"", + ""parentId"": ""0b6bb091c739e7aec2cb724378d50e486a914768"", + ""message"": ""docs: plan.md"", + ""refs"": ""HEAD -> dev, main"" + }, + { + ""id"": ""0b6bb091c739e7aec2cb724378d50e486a914768"", + ""parentId"": """", + ""message"": ""docs: plan.md"", + ""refs"": """" + } + ] +}",dev +7,커밋 메시지 수정하기,"당신은 프로젝트에서 회원가입, 로그인, 회원탈퇴 기능을 담당하게 되었습니다. 먼저 회원가입 기능을 구현한 다음 테스트 코드를 작성하고 commit을 생성했습니다. 그런데 commit에 테스트 파일이 포함되지 않았으며, commit message가 ""회원가입 긴ㅇ 구현""으로 오타가 났습니다. -`signup.test.js` 테스트 파일을 추가하고 commit message를 ""회원가입 기능 구현""로 수정해주세요.",Git Advanced,"status, add, commit" -8,git reset HEAD~1,"당신은 회원가입 기능 구현과 테스트 코드를 작성해 ""회원가입 기능 구현"" commit을 생성했습니다. +`signup.test.js` 테스트 파일을 추가하고 commit message를 ""회원가입 기능 구현""로 수정해주세요.",Git Advanced,"status, add, commit","`git` status\n`git` add signup.test.js\n`git` commit --amend\n# 커밋 메시지 수정 편집기에서 ""회원가입 기능 구현"" 으로 수정한 후 저장","{ + ""graph"": [ + { + ""id"": ""69dbecf5a107a00d7e9811a4622476c46f871fcb"", + ""parentId"": ""cbc7aa1ae41aab698ca64d9e571fc151f894bbe1"", + ""message"": ""회원가입 긴ㅇ 구현"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""cbc7aa1ae41aab698ca64d9e571fc151f894bbe1"", + ""parentId"": ""eb8628fb5064ed207b8bcd973747dcf5b7465a12"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, feat/somethingA"" + }, + { + ""id"": ""eb8628fb5064ed207b8bcd973747dcf5b7465a12"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +8,커밋 취소하기,"당신은 회원가입 기능 구현과 테스트 코드를 작성해 ""회원가입 기능 구현"" commit을 생성했습니다. 그러나 ""회원가입 기능 구현""과 ""회원가입 테스트 코드 작성"" 두 개의 commit으로 나누려고 합니다. -""회원가입 기능 구현"" commit을 취소하고 `signup.js` 파일로 ""회원가입 기능 구현"" commit을, `signup.test.js` 파일로 ""회원가입 테스트 코드 작성"" commit을 순서대로 생성해주세요.",Git Advanced,"reset, add, commit" -9,git restore,"당신이 코드를 작성하던 중에 실수로 `important.js` 파일을 수정해 변경 사항이 생겼습니다. -해당 파일을 최근 `commit` 상태로 되돌려주세요.",Git Advanced,restore -10,git clean,당신은 추적되지 않는 임시 파일을 세 개 만들었습니다. 임시 파일을 모두 삭제해주세요.,Git Advanced,clean -11,git stash,"당신이 로그인 기능을 구현하던 중 급하게 버그 픽스 요청이 들어왔습니다. +""회원가입 기능 구현"" commit을 취소하고 `signup.js` 파일로 ""회원가입 기능 구현"" commit을, `signup.test.js` 파일로 ""회원가입 테스트 코드 작성"" commit을 순서대로 생성해주세요.",Git Advanced,"reset, add, commit",`git` reset HEAD~1\n`git` add signup.js\n`git` commit -m '회원가입 기능 구현'\n`git` add signup.test.js\n`git` commit -m '회원가입 테스트 코드 작성',"{ + ""graph"": [ + { + ""id"": ""efaf77ae539a289b8b8e76f8a8289934bd32f8bf"", + ""parentId"": ""05caa6918c9b406a30bd0412ef5fbef21c52d124"", + ""message"": ""회원가입 기능 구현"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""05caa6918c9b406a30bd0412ef5fbef21c52d124"", + ""parentId"": ""cea2e002129c7543f13c16f0f970c2b43d74284c"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, feat/somethingA"" + }, + { + ""id"": ""cea2e002129c7543f13c16f0f970c2b43d74284c"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +9,파일 되돌리기,"당신이 코드를 작성하던 중에 실수로 `important.js` 파일을 수정해 변경 사항이 생겼습니다. +해당 파일을 최근 `commit` 상태로 되돌려주세요.",Git Advanced,restore,`git` restore important.js,"{ + ""graph"": [ + { + ""id"": ""0ee6f38e438211833c46d42b6acf087ba63d2cf0"", + ""parentId"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""parentId"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""parentId"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, feat/somethingA"" + }, + { + ""id"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +10,파일 삭제하기,당신은 추적되지 않는 임시 파일을 세 개 만들었습니다. 임시 파일을 모두 삭제해주세요.,Git Advanced,clean,`git` clean -f tmp.js\n`git` clean -f tmptmp.js\n`git` clean -f tmptmptmp.js,"{ + ""graph"": [ + { + ""id"": ""0ee6f38e438211833c46d42b6acf087ba63d2cf0"", + ""parentId"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""parentId"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""parentId"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, feat/somethingA"" + }, + { + ""id"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +11,변경 사항 저장하기,"당신이 로그인 기능을 구현하던 중 급하게 버그 픽스 요청이 들어왔습니다. 새로운 branch를 생성해서 작업하려 했지만, 변경 사항이 있어 branch 이동이 불가능합니다. -현재 기능 구현이 완료되지 않아 commit하는 것이 껄끄럽기 때문에 commit 없이 변경 사항을 보관하고, ""A 기능 구현"" commit으로 돌아가 `hotfix/fixA` 브랜치를 생성해주세요.",Git Advanced,"stash, log, checkout, switch" -12,git cherry-pick,"당신은 버그를 해결한 후 ""버그 수정"" commit을 생성했습니다. +현재 기능 구현이 완료되지 않아 commit하는 것이 껄끄럽기 때문에 commit 없이 변경 사항을 보관하고, ""A 기능 구현"" commit으로 돌아가 `hotfix/fixA` 브랜치를 생성해주세요.",Git Advanced,"stash, log, checkout, switch","`git` stash\n`git` log\n`git` checkout <""A 기능 구현"" 커밋 해시값>\n`git` switch -c hotfix/fixA","{ + ""graph"": [ + { + ""id"": ""50946bb407490b8aa17a82f55179b05f8ec05274"", + ""parentId"": ""0ee6f38e438211833c46d42b6acf087ba63d2cf0"", + ""message"": ""로그인 기능 구현"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""0ee6f38e438211833c46d42b6acf087ba63d2cf0"", + ""parentId"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""7f0f87852fa8225bfa5862b440e48480c339d2aa"", + ""parentId"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""55f7aab2f31287f6bb159731e5824566c02b582b"", + ""parentId"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, feat/somethingA"" + }, + { + ""id"": ""9bd1b91fba0c4b644e673a85cefbbf52b51b03e4"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +12,커밋 가져오기,"당신은 버그를 해결한 후 ""버그 수정"" commit을 생성했습니다. `hotfix/fixA` branch에서 작업을 해야 하는데 실수로 `feat/somethingB` branch에서 작업 했습니다. `feat/somthingB` branch 에서 ""버그 수정"" commit을 `hotfix/fixA` branch로 가져오고, `hotfix/fixA` branch를 `main` branch로 merge해주세요. -그리고 `feat/somethingB` branch에서 ""버그 수정"" commit을 취소해주세요.",Git Advanced,"log, switch, cherry-pick, merge, reset" -13,git rebase,"당신은 로그인 기능과 회원 탈퇴 기능 구현을 마쳤습니다. +그리고 `feat/somethingB` branch에서 ""버그 수정"" commit을 취소해주세요.",Git Advanced,"log, switch, cherry-pick, merge, reset","`git` log\n`git` switch hotfix/fixA\n`git` cherry-pick <""버그 수정"" 커밋 해시값>\n`git` switch main\n`git` merge hotfix/fixA\n`git` switch feat/somethingB\n`git` reset --hard HEAD^","{ + ""graph"": [ + { + ""id"": ""38bafc899bb9257644ac616ac0075431d8481e83"", + ""parentId"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""message"": ""버그 수정"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""parentId"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""parentId"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""parentId"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""message"": ""A 기능 구현"", + ""refs"": ""main, hotfix/fixA, feat/somethingA"" + }, + { + ""id"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +13,커밋 이력 조작하기,"당신은 로그인 기능과 회원 탈퇴 기능 구현을 마쳤습니다. `main` branch로 merge하기 전에 commit log를 확인해보니 다음과 같이 commit message에 오타가 났습니다. ""로그인 기느 ㄱ후ㅕㄴ"" -잘못 입력한 commit message를 ""로그인 기능 구현""으로 수정해주세요.",Git Advanced,"log, rebase" -14,git revert,"회원가입, 로그인, 회원 탈퇴 기능 구현을 끝내고 `feat/somethingB` branch를 `main` branch로 merge했습니다. +잘못 입력한 commit message를 ""로그인 기능 구현""으로 수정해주세요.",Git Advanced,"log, rebase",`git` log\n`git` rebase -i <'로그인 기느 ㄱ후ㅕㄴ' 커밋 이전 해시값>\n# rebase 편집기에서 '로그인 기느 ㄱ후ㅕㄴ' 커밋을 pick 에서 reword 로 변경한 후 종료\n# '로그인 기느 ㄱ후ㅕㄴ' 커밋 메시지 편집기에서 '로그인 기능 구현'으로 변경한 후 종료,"{ + ""graph"": [ + { + ""id"": ""d109f5148c80251cf104dfa6aad68fb5f59d8e92"", + ""parentId"": ""09dca006fc4b673df90542ae5076e86911d222c7"", + ""message"": ""회원탈퇴 기능 구현"", + ""refs"": ""HEAD -> feat/somethingB"" + }, + { + ""id"": ""09dca006fc4b673df90542ae5076e86911d222c7"", + ""parentId"": ""6a832792e297962412acf1acfdb56ab36fb77d78"", + ""message"": ""로그인 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""6a832792e297962412acf1acfdb56ab36fb77d78"", + ""parentId"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""message"": ""로그인 기느 ㄱ후ㅕㄴ"", + ""refs"": """" + }, + { + ""id"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""parentId"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""parentId"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""12de3a68e2f06dac7c9a0c66f04e9fd96064bd67"", + ""parentId"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""message"": ""버그 수정"", + ""refs"": ""main, hotfix/fixA"" + }, + { + ""id"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""parentId"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""message"": ""A 기능 구현"", + ""refs"": ""feat/somethingA"" + }, + { + ""id"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",feat/somethingB +14,변경 사항 되돌리기,"회원가입, 로그인, 회원 탈퇴 기능 구현을 끝내고 `feat/somethingB` branch를 `main` branch로 merge했습니다. 그런데 실수로 ""사용자 프로필 기능 구현"" commit까지 `main` branch에 merge 되었습니다. `main` branch는 다른 팀원들과 협업하는 branch라 기존 commit 기록이 변경되면 안 됩니다. -`git reset`이 아닌 다른 방법으로 ""사용자 프로필 기능 구현"" commit을 취소해주세요.",Git Advanced,"log, revert" -15,"git clone, upstream 등록","당신은 새로운 팀에 배정되었습니다. 이제부터 전임자의 일을 이어서 진행해야 합니다. +`git reset`이 아닌 다른 방법으로 ""사용자 프로필 기능 구현"" commit을 취소해주세요.",Git Advanced,"log, revert",`git` log\n`git` revert <'사용자 프로필 기능 구현' 커밋 해시값>,"{ + ""graph"": [ + { + ""id"": ""a4e0281b20419d871ce29e7eedba19f7ba662db5"", + ""parentId"": ""12de3a68e2f06dac7c9a0c66f04e9fd96064bd67 c5d41a925a1ad13da46fb91e62ca7a8e84769b29"", + ""message"": ""Merge branch 'feat/somethingB'"", + ""refs"": ""HEAD -> main"" + }, + { + ""id"": ""c5d41a925a1ad13da46fb91e62ca7a8e84769b29"", + ""parentId"": ""342a51c2b733cab8fd36b90d5889f9ae3b54dc4c"", + ""message"": ""사용자 프로필 기능 구현"", + ""refs"": ""feat/somethingB"" + }, + { + ""id"": ""342a51c2b733cab8fd36b90d5889f9ae3b54dc4c"", + ""parentId"": ""c8edc4a60953df6ee3cebbc5b100f2759f5ee5dd"", + ""message"": ""회원탈퇴 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""c8edc4a60953df6ee3cebbc5b100f2759f5ee5dd"", + ""parentId"": ""53cacfbad35d1f18c5d1c45e1d7445a3f9cd0adb"", + ""message"": ""로그인 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""53cacfbad35d1f18c5d1c45e1d7445a3f9cd0adb"", + ""parentId"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""message"": ""로그인 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""c01511a8d88b4355d754406ef6ec20aa49d69c5b"", + ""parentId"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""message"": ""회원가입 테스트 코드 작성"", + ""refs"": """" + }, + { + ""id"": ""ee0765a0f4bd5df20a595100b62c041100d64096"", + ""parentId"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""message"": ""회원가입 기능 구현"", + ""refs"": """" + }, + { + ""id"": ""12de3a68e2f06dac7c9a0c66f04e9fd96064bd67"", + ""parentId"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""message"": ""버그 수정"", + ""refs"": ""hotfix/fixA"" + }, + { + ""id"": ""d342b2a90ea54333b8e04899c25b7847241a3e8f"", + ""parentId"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""message"": ""A 기능 구현"", + ""refs"": ""feat/somethingA"" + }, + { + ""id"": ""dbf627f91961b1704755be952190b29210fc8f95"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",main +15,원격 저장소 등록하기,"당신은 새로운 팀에 배정되었습니다. 이제부터 전임자의 일을 이어서 진행해야 합니다. 배정된 팀의 저장소 전략은 다음과 같습니다. 1. 원본 저장소 `upstream`이 있다. @@ -40,24 +368,49 @@ id,title,description,category,keyword 3. `origin`에서 branch를 생성하여 작업한다. 4. 당신은 `origin`으로 push할 수 있으며, 필요에 따라 `upstream`으로 PR을 생성할 수 있다. -`origin` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-upstream.git -`upstream` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-origin.git +`origin` 저장소 디렉터리: /origin +`upstream` 저장소 디렉터리: /upstream -위 정보에 맞추어 `origin` 저장소를 로컬 저장소로 가져오고, `upstream` 저장소를 등록해주세요. (각각의 저장소의 이름도 동일해야 합니다.) +위 정보에 맞추어 `origin` 저장소를 로컬 저장소로 가져오고, `upstream` 저장소를 등록해주세요. -* 저장소를 가져올 때 현재 디렉터리로 가져와주세요.('.' 표현을 이용해야 합니다)",Remote Start,"clone, remote" -16,git switch -c,"당신은 ""somethingA""라는 기능을 구현하기 위한 branch를 생성하고자 합니다. 팀의 컨벤션에 따라 브랜치의 이름은 `feat/somethingA`입니다. -`feat/somethingA`라는 이름의 branch를 생성하고 해당 branch로 이동해주세요.",Remote Start,"switch, checkout" -17,git fetch & git pull,"당신이 개발한 기능을 포함한 모든 기능들이 `origin` 저장소의 `main` branch에 합쳐졌습니다. +* 저장소를 가져올 때 현재 디렉터리로 가져와주세요.('.' 표현을 이용해야 합니다)",Remote Start,"clone, remote",`git` clone /origin .\n`git` remote add upstream /upstream,"{ + ""graph"": [] +}", +16,브랜치 생성하고 이동하기,"당신은 ""somethingA""라는 기능을 구현하기 위한 branch를 생성하고자 합니다. 팀의 컨벤션에 따라 브랜치의 이름은 `feat/somethingA`입니다. +`feat/somethingA`라는 이름의 branch를 생성하고 해당 branch로 이동해주세요.",Remote Start,"switch, checkout",`git` switch -c feat/somethingA\n# or\n`git` checkout -b feat/somethingA,"{ + ""graph"": [ + { + ""id"": ""8971eea584f1de673816d47bc3e15502761ed832"", + ""parentId"": ""91240b0023f9a0990d636149fea18f7e19a40296"", + ""message"": ""feat: 로그인 로그아웃 구현"", + ""refs"": ""HEAD -> main, origin/main, origin/HEAD"" + }, + { + ""id"": ""91240b0023f9a0990d636149fea18f7e19a40296"", + ""parentId"": """", + ""message"": ""initial commit"", + ""refs"": """" + } + ] +}",main +17,브랜치 최신화하기,"당신이 개발한 기능을 포함한 모든 기능들이 `origin` 저장소의 `main` branch에 합쳐졌습니다. 그리고 당신은 `origin` 저장소의 `main` branch에서의 구현 결과를 다른 사람들에게 공유하고자 합니다. -로컬 저장소에는 다음과 같은 설정이 있습니다. +당신의 원격 저장소는 다음과 같습니다. +`origin` 저장소 디렉터리: /origin +`upstream` 저장소 디렉터리: /upstream -`origin` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-upstream.git -`upstream` 저장소 github 주소: https://github.com/flydog98/git-challenge-remote-origin.git - -현재 당신의 로컬 저장소의 코드는 `origin` 저장소의 버전에 비해 뒤쳐져 있습니다. 로컬 저장소의 코드를 최신화 해 주세요.",Remote Start,"pull, fetch, rebase" -18,git push,"당신은 새로운 팀에 배정되었고, 이제 막 첫 기능 개발을 완료했습니다. +현재 당신의 로컬 저장소의 코드는 `origin` 저장소의 버전에 비해 뒤쳐져 있습니다. 로컬 저장소의 코드를 최신화 해 주세요.",Remote Start,"pull, fetch, rebase",`git` pull origin main\n#or\n`git` fetch origin main\n`git` rebase origin/mainzx,"{ + ""graph"": [ + { + ""id"": ""e930ab7d9077037fe675792d382e1f69b6b2be75"", + ""parentId"": """", + ""message"": ""initial commit"", + ""refs"": ""HEAD -> main, origin/main, origin/HEAD"" + } + ] +}",main +18,원격 저장소로 보내기,"당신은 새로운 팀에 배정되었고, 이제 막 첫 기능 개발을 완료했습니다. 배정된 팀의 저장소 전략은 다음과 같습니다. 1. 원본 저장소 `upstream`이 있다. @@ -65,9 +418,116 @@ id,title,description,category,keyword 3. `origin`에서 branch를 생성하여 작업한다. 4. 당신은 `origin`으로 push할 수 있으며, 필요에 따라 `upstream`으로 PR을 생성할 수 있다. -당신은 `origin` 저장소를 복제한 뒤 `feat/merge-master`라는 branch에서 기능 개발을 완료했습니다. +`origin` 저장소 디렉터리: /origin +`upstream` 저장소 디렉터리: /upstream + +당신은 `origin` 저장소를 복제한 뒤 `feat/merge-master`라는 branch를 생성하여 기능 개발을 완료했습니다. 당신은 `mergeMaster.js` 라는 파일을 새로 생성하였으며, 기존에 있던 `MergeMasters.md` 파일을 수정했습니다. -위 정보에 맞추어 `origin`저장소에 `feat/merge-master` branch의 정보를 최신화하고자 한다면 어떤 명령을 수행해야 할까요?",Remote Start,"push, pull" -19,git branch --merged + git branch -d,"당신은 개발을 완료한 뒤 push한 commit들을 기반으로 Github 상에서 PR을 생성했고, 해당 PR은 성공적으로 merge 되었습니다. +위 정보에 맞추어 `origin`저장소에 `feat/merge-master` branch의 정보를 최신화 해 주세요.",Remote Start,"push, pull","`git` add .\n`git` commit -m ""feat: mergemaster 구현""\n`git` push -u origin feat/merge-master","{ + ""graph"": [ + { + ""id"": ""adc6a7e763d52bf82a64617cb7a739c82d3af2ca"", + ""parentId"": ""a33b1257ec9113aa484ad465d96a69921d9dd971 547986244e8813b6fe78502e5b09a7e01077c53d"", + ""message"": ""Merge branch 'featureE'"", + ""refs"": ""HEAD -> feat/merge-master, origin/main, origin/HEAD, main"" + }, + { + ""id"": ""547986244e8813b6fe78502e5b09a7e01077c53d"", + ""parentId"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""message"": ""Add featureE-1.js"", + ""refs"": ""origin/featureE"" + }, + { + ""id"": ""a33b1257ec9113aa484ad465d96a69921d9dd971"", + ""parentId"": ""f6b4647bb68c6890a0cce397c0931c72b4f4711c e9025a7ce024c7b5bd19d106b75db73a631684cf"", + ""message"": ""Merge branch 'featureD'"", + ""refs"": """" + }, + { + ""id"": ""e9025a7ce024c7b5bd19d106b75db73a631684cf"", + ""parentId"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""message"": ""Add featureD-1.js"", + ""refs"": ""origin/featureD"" + }, + { + ""id"": ""f6b4647bb68c6890a0cce397c0931c72b4f4711c"", + ""parentId"": ""05cb1f4633cc2d7f5097495f6f21fc667e1bb611 b99a280df8b3e2e065afab66eb778ae59301683c"", + ""message"": ""Merge branch 'featureC'"", + ""refs"": """" + }, + { + ""id"": ""b99a280df8b3e2e065afab66eb778ae59301683c"", + ""parentId"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""message"": ""Add featureC-1.js"", + ""refs"": ""origin/featureC"" + }, + { + ""id"": ""05cb1f4633cc2d7f5097495f6f21fc667e1bb611"", + ""parentId"": ""e030afde2b5db43af9fa0a286bed04efae2daad9 99760bf24ff9b814164e07b28d22363c03832076"", + ""message"": ""Merge branch 'featureB'"", + ""refs"": """" + }, + { + ""id"": ""99760bf24ff9b814164e07b28d22363c03832076"", + ""parentId"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""message"": ""Add featureB-1.js"", + ""refs"": ""origin/featureB"" + }, + { + ""id"": ""e030afde2b5db43af9fa0a286bed04efae2daad9"", + ""parentId"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""message"": ""Add featureA-1.js"", + ""refs"": ""origin/featureA"" + }, + { + ""id"": ""d7d30d98cd2ae1a2abf30416796cb09c5196f76a"", + ""parentId"": """", + ""message"": ""initial commit"", + ""refs"": """" + } + ] +}",feat/merge-master +19,브랜치 삭제하기,"당신은 개발을 완료한 뒤 push한 commit들을 기반으로 Github 상에서 PR을 생성했고, 해당 PR은 성공적으로 merge 되었습니다. merge된 branch를 포함해서 현재 로컬 저장소에는 필요 없는 branch들이 있습니다. -이제는 필요 없어진 branch들을 확인하고 모두 삭제해 주세요.",Remote Start,"switch, branch" \ No newline at end of file +이제는 필요 없어진 branch들을 확인하고 모두 삭제해 주세요.",Remote Start,"switch, branch",# merge 된 브랜치 확인\n`git` branch --merged\n`git` branch -d feat/somethingA\n`git` branch -d feat/somethingB,"{ + ""message"": "" feat/somethingA\n feat/somethingB\n feat/somethingC\n hotfix/somethingD\n* main\n"", + ""result"": ""success"", + ""graph"": [ + { + ""id"": ""98ae5764ae69e4e4879a6bc777fe290601f0b317"", + ""parentId"": ""90fb24d85d4e5f504d5c473991002449f07f39f8 fe66bd03c4ba634db8e3a5a1b52aed6609b17e4a"", + ""message"": ""Merge branch 'feat/somethingB'"", + ""refs"": ""HEAD -> main"" + }, + { + ""id"": ""fe66bd03c4ba634db8e3a5a1b52aed6609b17e4a"", + ""parentId"": ""94ccb6b3c806f429c452f644aa2ea06ce8888925"", + ""message"": ""Add featureB-1.js"", + ""refs"": ""feat/somethingB"" + }, + { + ""id"": ""90fb24d85d4e5f504d5c473991002449f07f39f8"", + ""parentId"": ""94ccb6b3c806f429c452f644aa2ea06ce8888925"", + ""message"": ""Add featureA-1.js"", + ""refs"": ""feat/somethingA"" + }, + { + ""id"": ""1a22d003ebca7aafb4d528f514f02e06e4bfdc2d"", + ""parentId"": ""94ccb6b3c806f429c452f644aa2ea06ce8888925"", + ""message"": ""Add hotfixD-1.js"", + ""refs"": ""hotfix/somethingD"" + }, + { + ""id"": ""8fc578d3283d9c0a251410e40036abf22dda779d"", + ""parentId"": ""94ccb6b3c806f429c452f644aa2ea06ce8888925"", + ""message"": ""Add featureC-1.js"", + ""refs"": ""feat/somethingC"" + }, + { + ""id"": ""94ccb6b3c806f429c452f644aa2ea06ce8888925"", + ""parentId"": """", + ""message"": ""Initial commit"", + ""refs"": """" + } + ] +}",main \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index 93563cd..3729479 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,17 +33,22 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.1.16", "@nestjs/typeorm": "^10.0.1", + "axios": "^1.6.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", + "dotenv": "^16.3.1", + "jest": "^29.7.0", "mongoose": "^8.0.1", "nest-winston": "^1.9.4", "papaparse": "^5.4.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "shell-escape": "^0.2.0", "sqlite3": "^5.1.6", "ssh2": "^1.14.0", "typeorm": "^0.3.17", + "uuid": "^9.0.1", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1" }, @@ -56,14 +61,15 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/papaparse": "^5", + "@types/shell-escape": "^0.2.3", "@types/ssh2": "^1", "@types/supertest": "^2.0.12", + "@types/uuid": "^9", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.53.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", "lint-staged": "^15.1.0", "prettier": "^3.1.0", "source-map-support": "^0.5.21", @@ -89,6 +95,10 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "testTimeout": 20000, + "globals": { + "NODE_ENV": "test" + } } } diff --git a/packages/backend/src/ai/ai.controller.spec.ts b/packages/backend/src/ai/ai.controller.spec.ts new file mode 100644 index 0000000..79720ad --- /dev/null +++ b/packages/backend/src/ai/ai.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AiController } from './ai.controller'; + +describe('AiController', () => { + let controller: AiController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AiController], + }).compile(); + + controller = module.get(AiController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/backend/src/ai/ai.controller.ts b/packages/backend/src/ai/ai.controller.ts new file mode 100644 index 0000000..da56505 --- /dev/null +++ b/packages/backend/src/ai/ai.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { AiRequestDto, AiResponseDto } from './dto/ai.dto'; +import { AiService } from './ai.service'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@Controller('api/v1/ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + @Post() + @ApiOperation({ summary: 'AI 답변을 받아옵니다.' }) + @ApiResponse({ + status: 200, + description: 'AI 답변을 받아옵니다.', + type: AiResponseDto, + }) + async ai(@Body() aiDto: AiRequestDto): Promise { + return await this.aiService.getApiResponse(aiDto.message); + } +} diff --git a/packages/backend/src/ai/ai.module.ts b/packages/backend/src/ai/ai.module.ts new file mode 100644 index 0000000..2f59dd3 --- /dev/null +++ b/packages/backend/src/ai/ai.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; + +@Module({ + controllers: [AiController], + providers: [AiService], +}) +export class AiModule {} diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts new file mode 100644 index 0000000..64b18c2 --- /dev/null +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AiService } from './ai.service'; + +describe('AiService', () => { + let service: AiService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AiService], + }).compile(); + + service = module.get(AiService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts new file mode 100644 index 0000000..c953d79 --- /dev/null +++ b/packages/backend/src/ai/ai.service.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { AiResponseDto } from './dto/ai.dto'; +import { Logger } from 'winston'; +import { preview } from '../common/util'; + +@Injectable() +export class AiService { + private readonly headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + private readonly instance = axios.create({ + baseURL: + 'https://clovastudio.stream.ntruss.com/testapp/v1/chat-completions/HCX-002', + timeout: 50000, + headers: this.headers, + }); + + constructor( + private configService: ConfigService, + @Inject('winston') private readonly logger: Logger, + ) { + this.instance.interceptors.request.use((config) => { + config.headers['X-NCP-CLOVASTUDIO-API-KEY'] = this.configService.get( + 'X_NCP_CLOVASTUDIO_API_KEY', + ); + config.headers['X-NCP-APIGW-API-KEY'] = this.configService.get( + 'X_NCP_APIGW_API_KEY', + ); + config.headers['X-NCP-CLOVASTUDIO-REQUEST-ID'] = this.configService.get( + 'X_NCP_CLOVASTUDIO_REQUEST_ID', + ); + return config; + }); + } + async getApiResponse(message: string): Promise { + const response = await this.instance.post('/', { + messages: [ + { + role: 'system', + content: + '- Git 전문가입니다.\\n- Git에 대한 질문만 대답합니다.\\n- Git 사용이 낯선 사람들에게 질문을 받습니다.\\n- Git 설치는 이미 마쳤습니다.\\n- 설명은 이해하기 쉽게 명료하고 간단하게 명령어 위주로 대답합니다.\\n- 질문한 것만 대답합니다.\\n- Git 명령어로만 해답을 제시합니다.\\n- 예를 들어 설명하지 않는다.', + }, + { + role: 'user', + content: message, + }, + ], + topP: 0.8, + topK: 0, + maxTokens: 512, + temperature: 0.3, + repeatPenalty: 5.0, + stopBefore: [], + includeAiFilters: true, + }); + + this.logger.log( + 'info', + `AI response: ${preview(response.data.result.message.content)}`, + ); + + return { message: response.data.result.message.content }; + } +} diff --git a/packages/backend/src/ai/dto/ai.dto.ts b/packages/backend/src/ai/dto/ai.dto.ts new file mode 100644 index 0000000..68b7c5b --- /dev/null +++ b/packages/backend/src/ai/dto/ai.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AiRequestDto { + @ApiProperty({ + description: '질문할 내용', + example: 'git이 뭐야?', + }) + message: string; +} + +export class AiResponseDto { + @ApiProperty({ + description: '답변 내용', + example: + 'Git은 분산 버전 관리 시스템(Distributed Version Control System)으로, 소스 코드의 버전을 관리하고 협업을 지원하는 도구입니다. Git은 다음과 같은 특징을 가지고 있습니다.\\n\\n1. **분산 저장소**: Git은 중앙 집중식 저장소가 아닌 분산 저장소를 사용합니다. 각 사용자는 자신의 컴퓨터에 저장소를 가지고 있으며, 이를 로컬 저장소라고 합니다.\\n2. **빠른 속도**: Git은 빠른 속도로 파일을 처리할 수 있습니다. 이는 Git이 데이터를 압축하여 저장하고, 해시 함수를 이용하여 파일을 빠르게 검색하기 때문입니다.\\n3. **버전 관리**: Git은 소스 코드의 버전을 관리합니다. 사용자는 파일을 수정하고 커밋(commit)하면, 해당 파일의 이전 버전과 이후 버전을 모두 저장할 수 있습니다.\\n4. **협업 지원**: Git은 협업을 지원합니다. 사용자는 다른 사용자와 함께 작업을 할 수 있으며, 서로의 작업 내용을 공유할 수 있습니다.\\n5. **명령어 기반**: Git은 명령어 기반으로 동작합니다. 사용자는 Git 명령어를 입력하여 저장소를 관리하고, 파일을 수정할 수 있습니다.\\n\\nGit은 다양한 프로그래밍 언어와 운영체제에서 사용할 수 있으며, 많은 개발자들이 Git을 이용하여 소스 코드를 관리하고 있습니다.', + }) + message: string; +} diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index d6f0a39..0bf8457 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -10,6 +10,9 @@ import { format } from 'winston'; import { typeOrmConfig } from './configs/typeorm.config'; import { QuizzesModule } from './quizzes/quizzes.module'; import { LoggingInterceptor } from './common/logging.interceptor'; +import { QuizWizardModule } from './quiz-wizard/quiz-wizard.module'; +import { AiModule } from './ai/ai.module'; +import { CommandModule } from './command/command.module'; @Module({ imports: [ @@ -38,6 +41,9 @@ import { LoggingInterceptor } from './common/logging.interceptor'; ), ), }), + QuizWizardModule, + AiModule, + CommandModule, ], controllers: [AppController], providers: [ diff --git a/packages/backend/src/command/command.module.ts b/packages/backend/src/command/command.module.ts new file mode 100644 index 0000000..dccd046 --- /dev/null +++ b/packages/backend/src/command/command.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CommandService } from './command.service'; + +@Module({ + providers: [CommandService], + exports: [CommandService], +}) +export class CommandModule {} diff --git a/packages/backend/src/command/command.service.spec.ts b/packages/backend/src/command/command.service.spec.ts new file mode 100644 index 0000000..4aa067f --- /dev/null +++ b/packages/backend/src/command/command.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CommandService } from './command.service'; + +describe('CommandService', () => { + let service: CommandService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CommandService], + }).compile(); + + service = module.get(CommandService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/backend/src/command/command.service.ts b/packages/backend/src/command/command.service.ts new file mode 100644 index 0000000..f5bed1c --- /dev/null +++ b/packages/backend/src/command/command.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { Logger } from 'winston'; +import { preview, processCarriageReturns } from '../common/util'; + +@Injectable() +export class CommandService { + private readonly host: string; + private readonly instance; + constructor( + private readonly configService: ConfigService, + @Inject('winston') private readonly logger: Logger, + ) { + this.host = this.configService.get('CONTAINER_SERVER_HOST'); + this.instance = axios.create({ + baseURL: this.host, + timeout: 10000, + }); + } + + async executeCommand( + ...commands: string[] + ): Promise<{ stdoutData: string; stderrData: string }> { + try { + const command = commands.join('; '); + this.logger.log('info', `command: ${preview(command, 40)}`); + const response = await this.instance.post('/', { command }); + return { + stdoutData: processCarriageReturns(response.data.stdoutData), + stderrData: processCarriageReturns(response.data.stderrData), + }; + } catch (error) { + this.logger.log('info', error); + } + } + + async executeCron( + ...commands: string[] + ): Promise<{ stdoutData: string; stderrData: string }> { + try { + const command = commands.join('; '); + this.logger.log('info', `command: ${preview(command, 40)}`); + const response = await this.instance.post('/cron', { command }); + return response.data; + } catch (error) { + this.logger.log('info', error); + } + } +} diff --git a/packages/backend/src/common/command.guard.ts b/packages/backend/src/common/command.guard.ts new file mode 100644 index 0000000..feb85a4 --- /dev/null +++ b/packages/backend/src/common/command.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; + +@Injectable() +export class CommandGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const mode = request.body['mode']; + const message = request.body['message']; + if ( + !( + typeof mode === 'string' && + typeof message === 'string' && + (mode === 'editor' || + (mode === 'command' && + message.startsWith('git') && + !this.isMessageIncluded(message, [ + ';', + '>', + '|', + '<', + '&', + '$', + '(', + ')', + '{', + '}', + ]))) + ) + ) { + throw new ForbiddenException('금지된 명령입니다'); + } + return true; + } + private isMessageIncluded(message: string, keywords: string[]): boolean { + return keywords.some((keyword) => message.includes(keyword)); + } +} diff --git a/packages/backend/src/common/execution-time.interceptor.ts b/packages/backend/src/common/execution-time.interceptor.ts new file mode 100644 index 0000000..bfaa4a6 --- /dev/null +++ b/packages/backend/src/common/execution-time.interceptor.ts @@ -0,0 +1,50 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { performance } from 'perf_hooks'; + +@Injectable() +export class ExecutionTimeInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const start = performance.now(); + const methodName = context.getHandler().name; // 현재 실행 중인 함수의 이름을 얻습니다. + const className = context.getClass().name; // 현재 실행 중인 클래스의 이름을 얻습니다. + + return next.handle().pipe( + tap(() => { + const duration = performance.now() - start; + console.log( + `${className}.${methodName} 실행 시간: ${duration.toFixed(2)} 밀리초`, + ); + }), + ); + } +} + +// 커스텀 데코레이터 정의 +export function MeasureExecutionTime() { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const start = performance.now(); + const result = await originalMethod.apply(this, args); + const duration = performance.now() - start; + console.log( + `${propertyKey} 함수 실행 시간: ${duration.toFixed(2)} 밀리초`, + ); + return result; + }; + + return descriptor; + }; +} diff --git a/packages/backend/src/common/util.ts b/packages/backend/src/common/util.ts new file mode 100644 index 0000000..3b44bff --- /dev/null +++ b/packages/backend/src/common/util.ts @@ -0,0 +1,73 @@ +import * as crypto from 'crypto'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export function preview(message: string, length?: number): string { + return message.length > 15 + ? message.slice(0, length ? length : 20) + '...' + : message; +} + +export function processCarriageReturns(data: string) { + return data + .split('\n') + .map((line) => { + const carriageReturnIndex = line.lastIndexOf('\r'); + return carriageReturnIndex !== -1 + ? line.substring(carriageReturnIndex + 1) + : line; + }) + .join('\n'); +} + +const algorithm = 'aes-256-cbc'; +const secretKey = process.env.SECRET_KEY; +const initializeVector = crypto.randomBytes(16); + +export function encryptObject(obj: any): string { + const cipher = crypto.createCipheriv( + algorithm, + Buffer.from(secretKey), + initializeVector, + ); + let encrypted = cipher.update(JSON.stringify(obj)); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return `${initializeVector.toString('hex')}:${encrypted.toString('hex')}`; +} + +export function decryptObject(encrypted: string): any { + const [iv, encryptedText] = encrypted + .split(':') + .map((part) => Buffer.from(part, 'hex')); + const decipher = crypto.createDecipheriv( + algorithm, + Buffer.from(secretKey), + iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return JSON.parse(decrypted.toString()); +} + +export function isStringArray(obj: unknown): obj is string[] { + return ( + Array.isArray(obj) && obj.every((element) => typeof element === 'string') + ); +} + +export function graphParser(graph: string) { + const lines = graph.split('\n'); + const graphParsed: object[] = []; + if (!graph) { + return graphParsed; + } + for (let i = 0; i < lines.length; i += 4) { + const id = lines[i]; + const parentId = lines[i + 1] || ''; + const message = lines[i + 2] || ''; + const refs = lines[i + 3] || ''; + graphParsed.push({ id, parentId, message, refs }); + } + return graphParsed; +} diff --git a/packages/backend/src/containers/containers.module.ts b/packages/backend/src/containers/containers.module.ts index ea228a1..b6df15b 100644 --- a/packages/backend/src/containers/containers.module.ts +++ b/packages/backend/src/containers/containers.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { ContainersService } from './containers.service'; +import { CommandModule } from '../command/command.module'; @Module({ + imports: [CommandModule], providers: [ContainersService], exports: [ContainersService], }) diff --git a/packages/backend/src/containers/containers.service.ts b/packages/backend/src/containers/containers.service.ts index 6c168aa..02db95b 100644 --- a/packages/backend/src/containers/containers.service.ts +++ b/packages/backend/src/containers/containers.service.ts @@ -1,104 +1,238 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Logger } from 'winston'; -import { CommandResponseDto } from 'src/quizzes/dto/command-response.dto'; -import { Client } from 'ssh2'; +import shellEscape from 'shell-escape'; +import { v4 as uuidv4 } from 'uuid'; +import { ActionType } from '../session/schema/session.schema'; +import { CommandService } from '../command/command.service'; + +const DOCKER_QUIZZER_COMMAND = 'docker exec -w /home/quizzer/quiz/ -u quizzer'; +const RETRY_DELAY = 500; +const MAX_RETRY = 3; +const GRAPH_COMMAND = `git log --branches --pretty=format:'%H%n%P%n%s%n%D' --topo-order`; +const GRAPH_ESCAPE = '89BDBC3136461-17189F6963D26-9F1BC6D53A3ED'; +const BRANCH_COMMAND = `git rev-parse --is-inside-work-tree &>/dev/null && (git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD) || echo ""`; +const BRANCH_ESCAPE = '23ASDF2312-ASDFAS223-ASDF2223'; @Injectable() export class ContainersService { + private availableContainers: Map = new Map(); + constructor( private configService: ConfigService, @Inject('winston') private readonly logger: Logger, - ) {} - - private async getSSH(): Promise { - const conn = new Client(); - - await new Promise((resolve, reject) => { - conn - .on('ready', () => resolve()) - .on('error', reject) - .connect({ - host: this.configService.get('CONTAINER_SSH_HOST'), - port: this.configService.get('CONTAINER_SSH_PORT'), - username: this.configService.get('CONTAINER_SSH_USERNAME'), - password: this.configService.get('CONTAINER_SSH_PASSWORD'), - }); - }); - - return conn; + private commandService: CommandService, + ) { + if (this.configService.get('SERVER_MODE') !== 'dev') { + this.initializeContainers(); + } } - private async executeSSHCommand( - command: string, - ): Promise<{ stdoutData: string; stderrData: string }> { - const conn: Client = await this.getSSH(); + async initializeContainers() { + for (let i: number = 1; i < 20; i++) { + const maxContainers = + this.configService.get('CONTAINER_POOL_MAX') || 1; + const containers = []; - return new Promise((resolve, reject) => { - conn.exec(command, (err, stream) => { - if (err) { - reject(new Error('SSH command execution Server error')); - return; - } - let stdoutData = ''; - let stderrData = ''; - stream - .on('close', () => { - conn.end(); - resolve({ stdoutData, stderrData }); - }) - .on('data', (chunk) => { - stdoutData += chunk; - }); - - stream.stderr.on('data', (chunk) => { - stderrData += chunk; - }); - }); - }); + for (let j = 0; j < maxContainers; j++) { + const containerId = await this.createContainer(i); + containers.push(containerId); + } + + this.availableContainers.set(i, containers); + } } + private getGitCommand(container: string, command: string): string { + return `${DOCKER_QUIZZER_COMMAND} ${container} ${command}`; + } async runGitCommand( container: string, command: string, - ): Promise { - const { stdoutData, stderrData } = await this.executeSSHCommand( - `docker exec -w ~/quiz/ ${container} ${command}`, + ): Promise<{ message: string; result: string; graph?: string; ref: string }> { + let { stdoutData, stderrData } = await this.commandService.executeCommand( + this.getGitCommand(container, command), + `${DOCKER_QUIZZER_COMMAND} ${container} sh -c "echo ${GRAPH_ESCAPE} 1>&2; echo ${GRAPH_ESCAPE}; ${GRAPH_COMMAND}; echo ${BRANCH_ESCAPE} 1>&2; echo ${BRANCH_ESCAPE}; ${BRANCH_COMMAND}"`, ); + const graphMessage = stdoutData + .slice( + stdoutData.indexOf(GRAPH_ESCAPE) + GRAPH_ESCAPE.length, + stdoutData.indexOf(BRANCH_ESCAPE), + ) + .trim(); + + const branchMessage = stdoutData + .slice(stdoutData.indexOf(BRANCH_ESCAPE) + BRANCH_ESCAPE.length) + .trim(); + + stdoutData = stdoutData.slice(0, stdoutData.indexOf(GRAPH_ESCAPE)); + stderrData = stderrData.slice(0, stderrData.indexOf(GRAPH_ESCAPE)); + + const patternIndex = stdoutData.indexOf('# CREATED_BY_OUTPUT.SH\n'); + if (patternIndex !== -1) { + const message = stdoutData.slice(0, patternIndex); + return { + message, + result: 'editor', + graph: graphMessage, + ref: branchMessage, + }; + } + if (stderrData) { - return { message: stderrData, result: 'fail' }; + return { + message: stderrData, + result: 'fail', + graph: graphMessage, + ref: branchMessage, + }; } - return { message: stdoutData, result: 'success' }; + return { + message: stdoutData, + result: 'success', + graph: graphMessage, + ref: branchMessage, + }; } - async getContainer(quizId: number): Promise { - // 일단은 컨테이너를 생성해 준다. - // 차후에는 준비된 컨테이너 중 하나를 선택해서 준다. - // quizId에 대한 유효성 검사는 이미 끝났다(이미 여기서는 DB 접근 불가) + private buildEditorCommand(message: string, command: string) { + const escapedMessage = shellEscape([message]); + + return `git config --global core.editor /editor/input.sh; echo ${escapedMessage} | ${command}; git config --global core.editor /editor/output.sh`; + } - const host: string = this.configService.get( - 'CONTAINER_SSH_USERNAME', + async runEditorCommand( + container: string, + command: string, + message: string, + ): Promise<{ message: string; result: string; graph: string; ref: string }> { + let { stdoutData, stderrData } = await this.commandService.executeCommand( + `${DOCKER_QUIZZER_COMMAND} ${container} sh -c "${this.buildEditorCommand( + message, + command, + )}; echo ${GRAPH_ESCAPE} 1>&2; echo ${GRAPH_ESCAPE}; ${GRAPH_COMMAND}; echo ${BRANCH_ESCAPE} 1>&2; echo ${BRANCH_ESCAPE}; ${BRANCH_COMMAND}"`, ); - const createContainerCommand = `docker run --network none -itd mergemasters/alpine-git:0.1 /bin/sh`; - const { stdoutData } = await this.executeSSHCommand(createContainerCommand); - const containerId = stdoutData.trim(); + const graphMessage = stdoutData + .slice( + stdoutData.indexOf(GRAPH_ESCAPE) + GRAPH_ESCAPE.length, + stdoutData.indexOf(BRANCH_ESCAPE), + ) + .trim(); + + const branchMessage = stdoutData + .slice(stdoutData.indexOf(BRANCH_ESCAPE) + BRANCH_ESCAPE.length) + .trim(); + + stdoutData = stdoutData.slice(0, stdoutData.indexOf(GRAPH_ESCAPE)); + stderrData = stderrData.slice(0, stderrData.indexOf(GRAPH_ESCAPE)); + + if (stderrData) { + return { + message: stderrData, + result: 'fail', + graph: graphMessage, + ref: branchMessage, + }; + } + + return { + message: stdoutData, + result: 'success', + graph: graphMessage, + ref: branchMessage, + }; + } + + async createContainer(quizId: number): Promise { + const user: string = this.configService.get( + 'CONTAINER_GIT_USERNAME', + ); - const createDirectoryCommand = `docker exec ${containerId} mkdir -p /${host}/quiz/`; - await this.executeSSHCommand(createDirectoryCommand); + const containerId = uuidv4(); - const copyFilesCommand = `docker cp ~/quizzes/${quizId}/. ${containerId}:/${host}/quiz/`; - await this.executeSSHCommand(copyFilesCommand); + const createContainerCommand = `docker run -itd --network none -v ~/editor:/editor \ +--name ${containerId} mergemasters/alpine-git:0.2 /bin/sh`; + const copyFilesCommand = `docker cp ~/quizzes/${quizId}/. ${containerId}:/home/${user}/quiz/`; + const copyOriginCommand = `[ -d ~/origins/${quizId} ] && docker cp ~/origins/${quizId}/. ${containerId}:/origin/`; + const copyUpstreamCommand = `[ -d ~/upstreams/${quizId} ] && docker cp ~/upstreams/${quizId}/. ${containerId}:/upstream/`; + const chownCommand = `docker exec -u root ${containerId} chown -R ${user}:${user} /home/${user}`; + const chownOriginCommand = `[ -d ~/origins/${quizId} ] && docker exec -u root ${containerId} chown -R ${user}:${user} /origin`; + const chownUpstreamCommand = `[ -d ~/upstreams/${quizId} ] && docker exec -u root ${containerId} chown -R ${user}:${user} /remote`; + const coreEditorCommand = `docker exec -w /home/quizzer/quiz/ -u ${user} ${containerId} git config --global core.editor /editor/output.sh`; + const mainBranchCommand = `docker exec -w /home/quizzer/quiz/ -u ${user} ${containerId} git config --global init.defaultbranch main`; + const containerDirectoryCommand = `mkdir /root/store/${containerId}`; + const containerDirectoryCpCommand = `docker cp ${containerId}:/home/quizzer/quiz/. /root/store/${containerId}/`; + await this.commandService.executeCommand( + createContainerCommand, + copyFilesCommand, + copyOriginCommand, + copyUpstreamCommand, + chownCommand, + chownOriginCommand, + chownUpstreamCommand, + coreEditorCommand, + mainBranchCommand, + containerDirectoryCommand, + containerDirectoryCpCommand, + ); return containerId; } + async getContainer( + quizIdParam: number | string, + retry = MAX_RETRY, + ): Promise { + const quizId = + typeof quizIdParam === 'string' ? parseInt(quizIdParam, 10) : quizIdParam; + + if (this.configService.get('SERVER_MODE') === 'dev') { + return this.createContainer(quizId); + } + + if (this.availableContainers.get(quizId).length > 0) { + const containerId = this.availableContainers.get(quizId).shift(); + + this.commandService.executeCron( + `(sleep 1800; docker rm -f ${containerId} >/dev/null 2>&1) &`, + ); + + this.createContainer(quizId).then((containerId) => { + this.availableContainers.get(quizId).push(containerId); + }); + + // if (!(await this.isValidateContainerId(containerId))) { + // return await this.createContainer(quizId); + // } + + return containerId; + } + + if (retry <= 0) { + throw new Error('No available containers after maximum retries'); + } + + // 재시도 로직 + return new Promise((resolve, reject) => { + setTimeout(async () => { + try { + const containerId = await this.getContainer(quizId, retry - 1); + resolve(containerId); + } catch (error) { + reject(error); + } + }, RETRY_DELAY); + }); + } + async isValidateContainerId(containerId: string): Promise { - const command = `docker ps -a --filter "id=${containerId}" --format "{{.ID}}"`; + const command = `docker ps -a --filter "name=${containerId}" --format "{{.ID}}"`; - const { stdoutData, stderrData } = await this.executeSSHCommand(command); + const { stdoutData, stderrData } = + await this.commandService.executeCommand(command); if (stderrData) { // 도커 미설치 등의 에러일 듯 @@ -111,12 +245,44 @@ export class ContainersService { async deleteContainer(containerId: string): Promise { const command = `docker rm -f ${containerId}`; - const { stdoutData, stderrData } = await this.executeSSHCommand(command); + const { stdoutData, stderrData } = + await this.commandService.executeCommand(command); - console.log(`container deleted : ${stdoutData}`); + this.logger.log('info', `container deleted : ${stdoutData.trim()}`); + this.logger.log('info', `container deleted error : ${stderrData.trim()}`); + } - if (stderrData) { - throw new Error(stderrData); - } + private buildDockerCommand(container: string, ...commands: string[]): string { + const command = commands.join('; '); + return `${DOCKER_QUIZZER_COMMAND} ${container} sh -c "${command}"`; + } + + async restoreContainer(logObject: { + status: string; + logs: { + mode: ActionType; + message: string; + }[]; + containerId: string; + }): Promise { + this.logger.log('info', 'restoring container...'); + const { logs, containerId } = logObject; + + let recentMessage = ''; + + const commands: string[] = logs.map((log) => { + if (log.mode === 'command') { + recentMessage = log.message; + return log.message; + } else if (log.mode === 'editor') { + return this.buildEditorCommand(log.message, recentMessage); + } else { + throw new Error('Invalid log mode'); + } + }); + + await this.commandService.executeCommand( + this.buildDockerCommand(containerId, ...commands), + ); } } diff --git a/packages/backend/src/quiz-wizard/magic.ts b/packages/backend/src/quiz-wizard/magic.ts new file mode 100644 index 0000000..ed59f02 --- /dev/null +++ b/packages/backend/src/quiz-wizard/magic.ts @@ -0,0 +1,133 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'winston'; +import { CommandService } from '../command/command.service'; + +@Injectable() +export class Magic { + constructor( + private commandService: CommandService, + @Inject('winston') private readonly logger: Logger, + ) {} + + async isDirectoryExist(container: string, path: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -w /home/quizzer/quiz/ -u quizzer ${container} ls -la | grep '^d.* ${path}$'`, + ); + + return stdoutData !== ''; + } + + async isFileExist(container: string, path: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -w /home/quizzer/quiz/ -u quizzer ${container} ls | grep '^${path}$'`, + ); + + return stdoutData !== ''; + } + + async isBranchExist(container: string, branch: string): Promise { + const command = `docker exec -w /home/quizzer/quiz/ -u quizzer ${container} git branch --list '${branch}'`; + const { stdoutData } = await this.commandService.executeCommand(command); + + return stdoutData.trim() !== ''; + } + + async getConfig(container: string, key: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} git -C /home/quizzer/quiz config user.${key}`, + ); + + return stdoutData.trim(); + } + + async getCachedDiff(container: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} git diff --cached`, + ); + + return stdoutData; + } + + async getTreeHead(container: string, branch: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git cat-file -p \\\$(git rev-parse ${branch}) | grep tree | awk '{print \\\$2}'"`, + ); + + return stdoutData.trim(); + } + + async getCommitHashByMessage( + container: string, + branch: string, + message: string, + ): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git log --grep='^${message}$' --oneline --reverse ${branch} | awk '{print \\\$1}'"`, + ); + + return stdoutData.trim(); + } + + async getHashObject(container: string, filename: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git hash-object ${filename}"`, + ); + + return stdoutData.trim(); + } + + async getRemotes(container: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git remote -v"`, + ); + + return stdoutData; + } + + async getNowBranch(container: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git rev-parse --abbrev-ref HEAD"`, + ); + + return stdoutData.trim(); + } + + async getAllBranch(container: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git branch | cut -c 3-"`, + ); + + return stdoutData; + } + + async getRecentStashPatch(container: string): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /home/quizzer/quiz ${container} sh -c "git stash show -p"`, + ); + + return stdoutData; + } + + async isBranchExistRemote( + container: string, + remote: string, + branch: string, + ): Promise { + const command = `docker exec -u quizzer -w /${remote} ${container} git branch --list '${branch}'`; + const { stdoutData } = await this.commandService.executeCommand(command); + + return stdoutData.trim() !== ''; + } + + async getTreeHeadRemote( + container: string, + remote: string, + branch: string, + ): Promise { + const { stdoutData } = await this.commandService.executeCommand( + `docker exec -u quizzer -w /${remote} ${container} sh -c "git cat-file -p \\\$(git rev-parse ${branch}) | grep tree | awk '{print \\\$2}'"`, + ); + + return stdoutData.trim(); + } +} diff --git a/packages/backend/src/quiz-wizard/quiz-wizard.module.ts b/packages/backend/src/quiz-wizard/quiz-wizard.module.ts new file mode 100644 index 0000000..b24f2df --- /dev/null +++ b/packages/backend/src/quiz-wizard/quiz-wizard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { QuizWizardService } from './quiz-wizard.service'; +import { Magic } from './magic'; +import { CommandModule } from '../command/command.module'; + +@Module({ + imports: [CommandModule], + providers: [QuizWizardService, Magic], + exports: [QuizWizardService], +}) +export class QuizWizardModule {} diff --git a/packages/backend/src/quiz-wizard/quiz-wizard.service.ts b/packages/backend/src/quiz-wizard/quiz-wizard.service.ts new file mode 100644 index 0000000..16e8094 --- /dev/null +++ b/packages/backend/src/quiz-wizard/quiz-wizard.service.ts @@ -0,0 +1,329 @@ +import { Injectable } from '@nestjs/common'; +import { Magic } from './magic'; + +@Injectable() +export class QuizWizardService { + constructor(private magic: Magic) {} + + async submit(containerId: string, quizId: number) { + const checker = this[`checkCondition${quizId}`]; + if (checker) { + return await checker.call(this, containerId); + } + return false; + } + + async checkCondition1(containerId: string): Promise { + return await this.magic.isDirectoryExist(containerId, '.git'); + } + + async checkCondition2(containerId: string): Promise { + if ((await this.magic.getConfig(containerId, 'name')) === 'MergeMaster') + return false; + if ( + (await this.magic.getConfig(containerId, 'email')) === + 'mergemaster@gitchallenge.com' + ) + return false; + + return true; + } + + async checkCondition3(containerId: string): Promise { + return ( + (await this.magic.getCachedDiff(containerId)) === + `diff --git a/README.md b/README.md +new file mode 100644 +index 0000000..e69de29 +diff --git a/docs/plan.md b/docs/plan.md +index e69de29..3b18e51 100644 +--- a/docs/plan.md ++++ b/docs/plan.md +@@ -0,0 +1 @@ ++hello world +` + ); + } + + async checkCondition4(containerId: string): Promise { + return ( + (await this.magic.getTreeHead(containerId, 'main')) === + '2d9c4be41cfcd733671742229782ff0ee390cce3' + ); + } + + async checkCondition5(containerId: string): Promise { + return (await this.magic.isBranchExist(containerId, 'dev')) === true; + } + + async checkCondition6(containerId: string): Promise { + if ( + (await this.magic.getTreeHead(containerId, 'dev')) !== + '4e66c710f89b80ee0424831c0d1257d329d6d1a3' + ) + return false; + + if ((await this.magic.getNowBranch(containerId)) !== 'main') return false; + + return true; + } + + async checkCondition7(containerId: string): Promise { + const amendCommitHash = await this.magic.getCommitHashByMessage( + containerId, + 'feat/somethingB', + '회원가입 기능 구현', + ); + if ( + !amendCommitHash || + (await this.magic.getTreeHead(containerId, amendCommitHash)) !== + '3c363aeb69b28b176bf565dba6bb8a3a92d9fd5d' + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, `${amendCommitHash}~1`)) !== + 'bebe52f7d1c8440fb4b1af9aa70ad9523d56336b' + ) { + return false; + } + + return true; + } + + async checkCondition8(containerId: string): Promise { + try { + const commitHash = await this.magic.getCommitHashByMessage( + containerId, + 'feat/somethingB', + '회원가입 테스트 코드 작성', + ); + if ( + !commitHash || + (await this.magic.getTreeHead(containerId, commitHash)) !== + '3c363aeb69b28b176bf565dba6bb8a3a92d9fd5d' + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, `${commitHash}~1`)) !== + 'eeee188ee95190bf884e106326de84f1051b9ea1' + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, `${commitHash}~2`)) !== + 'bebe52f7d1c8440fb4b1af9aa70ad9523d56336b' + ) { + return false; + } + + const commitHashSecond = await this.magic.getCommitHashByMessage( + containerId, + 'feat/somethingB', + '회원가입 기능 구현', + ); + if ( + (await this.magic.getTreeHead(containerId, `${commitHashSecond}`)) !== + 'eeee188ee95190bf884e106326de84f1051b9ea1' + ) { + return false; + } + + return true; + } catch { + return false; + } + } + + async checkCondition9(containerId: string): Promise { + return ( + (await this.magic.getHashObject(containerId, 'important.js')) === + 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' + ); + } + + async checkCondition10(containerId: string): Promise { + if (await this.magic.isFileExist(containerId, 'tmp.js')) { + return false; + } + if (await this.magic.isFileExist(containerId, 'tmptmp.js')) { + return false; + } + if (await this.magic.isFileExist(containerId, 'tmptmptmp.js')) { + return false; + } + + return true; + } + + async checkCondition11(containerId: string): Promise { + if (!(await this.magic.isBranchExist(containerId, 'hotfix/fixA'))) { + return false; + } + + if ( + (await this.magic.getRecentStashPatch(containerId)) !== + `diff --git a/login.js b/login.js +index e69de29..bf5c54f 100644 +--- a/login.js ++++ b/login.js +@@ -0,0 +1 @@ ++console.log("implementing login") +` + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, 'hotfix/fixA')) !== + 'bebe52f7d1c8440fb4b1af9aa70ad9523d56336b' + ) { + return false; + } + + return true; + } + + async checkCondition12(containerId: string): Promise { + if ( + (await this.magic.getTreeHead(containerId, 'main')) !== + 'b9671d4553366d5609ae74fcc11f9f737fc859bd' + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, 'hotfix/fixA')) !== + 'b9671d4553366d5609ae74fcc11f9f737fc859bd' + ) { + return false; + } + + if ( + (await this.magic.getTreeHead(containerId, 'feat/somethingB')) !== + '3c363aeb69b28b176bf565dba6bb8a3a92d9fd5d' + ) { + return false; + } + + return true; + } + + async checkCondition13(containerId: string): Promise { + try { + const messages = [ + '회원탈퇴 기능 구현', + '로그인 테스트 코드 작성', + '로그인 기능 구현', + ]; + const hashes = [ + 'ad5fe01b96edeab9ebd213a4069ea9d6f313eec3', + '64d5e0c4675ca4b51d1e5e66bbcc703bc785308f', + '86eff1319c698fdd99b9c2289d4f66f0498ca040', + '3c363aeb69b28b176bf565dba6bb8a3a92d9fd5d', + ]; + + for (const [index, message] of messages.entries()) { + const commitHash = await this.magic.getCommitHashByMessage( + containerId, + 'feat/somethingB', + message, + ); + + if ( + !commitHash || + (await this.magic.getTreeHead(containerId, commitHash)) !== + hashes[index] || + (await this.magic.getTreeHead(containerId, `${commitHash}~1`)) !== + hashes[index + 1] + ) { + return false; + } + } + + return true; + } catch { + return false; + } + } + + async checkCondition14(containerId: string): Promise { + if ( + (await this.magic.getTreeHead(containerId, 'main')) !== + '2f3177d100b90c5a605e97c2fc546502cee2d4a6' + ) + return false; + + return true; + } + + async checkCondition15(containerId: string): Promise { + if ( + (await this.magic.getTreeHead(containerId, 'main')) !== + '13a3ee29a5743442145beffa234ad373548d8c59' + ) + return false; + + if ( + (await this.magic.getRemotes(containerId)) !== + `origin /origin (fetch) +origin /origin (push) +upstream /upstream (fetch) +upstream /upstream (push) +` + ) { + return false; + } + + return true; + } + + async checkCondition16(containerId: string): Promise { + return (await this.magic.getNowBranch(containerId)) === 'feat/somethingA'; + } + + async checkCondition17(containerId: string): Promise { + return ( + (await this.magic.getTreeHead(containerId, 'main')) === + '3bd4e8ab14ecf1c7e1e85262ce241c4275080270' + ); + } + + async checkCondition18(containerId: string): Promise { + if ( + !(await this.magic.isBranchExistRemote( + containerId, + 'origin', + 'feat/merge-master', + )) + ) { + return false; + } + + if ( + (await this.magic.getTreeHeadRemote( + containerId, + 'origin', + 'feat/merge-master', + )) !== 'd173eb2b7c6888bf77fce84b191a433f36c47a91' + ) { + return false; + } + + return true; + } + + async checkCondition19(containerId: string): Promise { + return ( + (await this.magic.getAllBranch(containerId)) === + `feat/somethingC +hotfix/somethingD +main +` + ); + } +} diff --git a/packages/backend/src/quizzes/dto/command-request.dto.ts b/packages/backend/src/quizzes/dto/command-request.dto.ts index 6efdc74..8018439 100644 --- a/packages/backend/src/quizzes/dto/command-request.dto.ts +++ b/packages/backend/src/quizzes/dto/command-request.dto.ts @@ -1,6 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; +export const MODE = { + COMMAND: 'command', + EDITOR: 'editor', +} as const; + +type ModeType = (typeof MODE)[keyof typeof MODE]; + export class CommandRequestDto { - @ApiProperty({ description: '실행할 명령문', example: 'git branch' }) - command: string; + @ApiProperty({ + description: + '실행할 명령 모드. 예: "command" (명령 실행), "editor" (에디터 명령)', + example: 'command', + enum: Object.values(MODE), + }) + mode: ModeType; + + @ApiProperty({ + description: + '실행할 명령문 or 에디터 작성 본문. 예: "git status (명령 실행), "feat: tmp.js 파일 제거" (에디터 명령)', + example: 'git status', + }) + message: string; } diff --git a/packages/backend/src/quizzes/dto/command-response.dto.ts b/packages/backend/src/quizzes/dto/command-response.dto.ts index a8c35aa..ae241b0 100644 --- a/packages/backend/src/quizzes/dto/command-response.dto.ts +++ b/packages/backend/src/quizzes/dto/command-response.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; +export const RESULT = { + SUCCESS: 'success', + FAIL: 'fail', + EDITOR: 'editor', +} as const; + +type ResultType = (typeof RESULT)[keyof typeof RESULT]; + export class CommandResponseDto { @ApiProperty({ description: '실행한 stdout/stderr 결과', @@ -8,14 +16,47 @@ export class CommandResponseDto { message: string; @ApiProperty({ - description: '실행 결과 요약(stdout => success, stderr => fail, vi)', + description: `실행 결과 요약(stdout => "success", stderr => "fail", 에디터 사용 => "editor")`, example: 'success', }) - result: 'success' | 'fail' | 'vi'; + result: ResultType; + + @ApiProperty({ + description: 'git 그래프 상황', + example: + '[\n' + + ' {\n' + + ' "id": "0b6bb091c739e7aec2cb724378d50e486a914768",\n' + + ' "parentId": "",\n' + + ' "message": "docs: plan.md",\n' + + ' "refs": "HEAD -> main"\n' + + ' }\n' + + ']\n', + }) + graph?: object[]; + + @ApiProperty({ + description: '현재 브랜치(reference)위치', + example: 'main', + }) + ref: string; +} +export class ForbiddenResponseDto { + @ApiProperty({ + description: '금지된 명령이거나, editor를 연속으로 사용했을때', + example: '금지된 명령입니다', + }) + message: string; + + @ApiProperty({ + description: `Forbidden`, + example: 'Forbidden', + }) + error: string; @ApiProperty({ - description: 'git 그래프 상황(아직 미구현)', - example: '아직 미구현이에요', + description: `statusCode`, + example: 403, }) - graph?: string; + statusCode: number; } diff --git a/packages/backend/src/quizzes/dto/graph.dto.ts b/packages/backend/src/quizzes/dto/graph.dto.ts new file mode 100644 index 0000000..c280e91 --- /dev/null +++ b/packages/backend/src/quizzes/dto/graph.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GraphDto { + @ApiProperty({ + description: 'git 그래프 상황', + example: + '[\n' + + ' {\n' + + ' "id": "0b6bb091c739e7aec2cb724378d50e486a914768",\n' + + ' "parentId": "",\n' + + ' "message": "docs: plan.md",\n' + + ' "refs": "HEAD -> main"\n' + + ' }\n' + + ']\n', + }) + graph: object[]; + + @ApiProperty({ + description: '현재 브랜치(reference)위치', + example: 'main', + }) + ref: string; +} diff --git a/packages/backend/src/quizzes/dto/quiz.dto.ts b/packages/backend/src/quizzes/dto/quiz.dto.ts index 00eaec4..353d89f 100644 --- a/packages/backend/src/quizzes/dto/quiz.dto.ts +++ b/packages/backend/src/quizzes/dto/quiz.dto.ts @@ -26,4 +26,11 @@ export class QuizDto { @IsString() @ApiProperty({ description: '문제 카테고리', example: 'Git Start' }) readonly category: string; + + @IsString() + @ApiProperty({ + description: '모범 답안', + example: ['`git` status', '`git` add README.md docs/plan.md'], + }) + readonly answer: string[]; } diff --git a/packages/backend/src/quizzes/dto/quizzes.dto.ts b/packages/backend/src/quizzes/dto/quizzes.dto.ts index 66c603d..a16e180 100644 --- a/packages/backend/src/quizzes/dto/quizzes.dto.ts +++ b/packages/backend/src/quizzes/dto/quizzes.dto.ts @@ -1,4 +1,3 @@ -// problem.dto.ts import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsInt, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; @@ -49,3 +48,23 @@ export class QuizzesDto { }) categories: CategoryQuizzesDto[]; } + +export class NotFoundResponseDto { + @ApiProperty({ + description: '없는 문제를 조회했을때', + example: 'Quiz 1212 not found', + }) + message: string; + + @ApiProperty({ + description: `Not Found`, + example: 'Not Found', + }) + error: string; + + @ApiProperty({ + description: `statusCode`, + example: 404, + }) + statusCode: number; +} diff --git a/packages/backend/src/quizzes/dto/shared.dto.ts b/packages/backend/src/quizzes/dto/shared.dto.ts new file mode 100644 index 0000000..5bbdb53 --- /dev/null +++ b/packages/backend/src/quizzes/dto/shared.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray } from 'class-validator'; +import { QuizDto } from './quiz.dto'; + +export class Decrypted { + readonly id: string; + readonly commands: string[]; +} + +export function isDecrypted(obj: unknown): obj is Decrypted { + const isObject = (val: unknown): val is { [key: string]: unknown } => + typeof val === 'object' && val !== null; + + if (!isObject(obj)) { + return false; + } + + return ( + typeof obj.id === 'string' && + Array.isArray(obj.commands) && + obj.commands.every((cmd) => typeof cmd === 'string') + ); +} + +export class SharedDto { + @ApiProperty({ + description: '공유받은 답안', + example: '["git status", "git add docs/plan.md"]', + }) + @IsArray() + readonly answer: string[]; + + @ApiProperty({ + description: '공유받은 답안의 문제 상황', + example: { + id: 3, + title: 'git add & git status', + description: + '현재 변경된 파일 중에서 `achitecture.md` 파일을 제외하고 staging 해주세요.', + keywords: ['add', 'status'], + category: 'Git Start', + }, + }) + readonly quiz: QuizDto; + + constructor(answer: string[], quiz: QuizDto) { + this.answer = answer; + this.quiz = quiz; + } +} + +export class BadRequestResponseDto { + @ApiProperty({ + description: + '제공된 암호화된 문자열이 유효하지 않거나, 복호화에 실패했습니다.', + example: '공유된 문제가 올바르지 않습니다.', + }) + message: string; + + @ApiProperty({ + description: `Bad Request`, + example: 'Bad Request', + }) + error?: string; + + @ApiProperty({ + description: `statusCode`, + example: 400, + }) + statusCode: number; + + constructor(message: string, error?: string) { + this.message = message; + this.statusCode = 400; + this.error = error; + } +} diff --git a/packages/backend/src/quizzes/dto/submit.dto.ts b/packages/backend/src/quizzes/dto/submit.dto.ts new file mode 100644 index 0000000..6156b4e --- /dev/null +++ b/packages/backend/src/quizzes/dto/submit.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsString } from 'class-validator'; + +export class Success { + @ApiProperty({ example: true }) + @IsBoolean() + solved = true; + + @ApiProperty({ + description: '인코딩된 문제 풀이 과정과 문제 번호', + example: '6251ee88d6e378b5d6b862447d151dab:aa88c19acf3da6(좀 더 길어요)', + }) + @IsString() + slug: string; + + constructor(slug: string) { + this.slug = slug; + } +} + +export class Fail { + @ApiProperty({ example: false }) + solved = false; +} + +export type SubmitDto = Success | Fail; diff --git a/packages/backend/src/quizzes/entity/quiz.entity.ts b/packages/backend/src/quizzes/entity/quiz.entity.ts index 24ceb96..b791c96 100644 --- a/packages/backend/src/quizzes/entity/quiz.entity.ts +++ b/packages/backend/src/quizzes/entity/quiz.entity.ts @@ -27,4 +27,13 @@ export class Quiz extends BaseEntity { @ManyToMany(() => Keyword, (keyword) => keyword.quizzes) @JoinTable() keywords: Keyword[]; + + @Column() + answer: string; + + @Column() + graph: string; + + @Column() + ref: string; } diff --git a/packages/backend/src/quizzes/quiz.guard.ts b/packages/backend/src/quizzes/quiz.guard.ts new file mode 100644 index 0000000..75f4d49 --- /dev/null +++ b/packages/backend/src/quizzes/quiz.guard.ts @@ -0,0 +1,20 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { QuizzesService } from './quizzes.service'; + +@Injectable() +export class QuizGuard implements CanActivate { + constructor(private readonly quizService: QuizzesService) {} + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const quizId = request.params.id; + if (!(await this.quizService.isQuizExist(quizId))) { + throw new NotFoundException(`Quiz ${quizId} not found`); + } + return true; + } +} diff --git a/packages/backend/src/quizzes/quizzes.controller.ts b/packages/backend/src/quizzes/quizzes.controller.ts index 08aa6a9..e73f195 100644 --- a/packages/backend/src/quizzes/quizzes.controller.ts +++ b/packages/backend/src/quizzes/quizzes.controller.ts @@ -7,9 +7,12 @@ import { HttpException, HttpStatus, Res, - Req, Inject, Delete, + UseGuards, + HttpCode, + UseInterceptors, + Query, } from '@nestjs/common'; import { ApiTags, @@ -17,41 +20,54 @@ import { ApiResponse, ApiParam, ApiBody, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiQuery, + ApiBadRequestResponse, } from '@nestjs/swagger'; import { Logger } from 'winston'; import { QuizDto } from './dto/quiz.dto'; import { QuizzesService } from './quizzes.service'; -import { QuizzesDto } from './dto/quizzes.dto'; -import { CommandRequestDto } from './dto/command-request.dto'; -import { CommandResponseDto } from './dto/command-response.dto'; +import { NotFoundResponseDto, QuizzesDto } from './dto/quizzes.dto'; +import { CommandRequestDto, MODE } from './dto/command-request.dto'; +import { + CommandResponseDto, + ForbiddenResponseDto, +} from './dto/command-response.dto'; import { SessionService } from '../session/session.service'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { ContainersService } from '../containers/containers.service'; +import { SessionId } from '../session/session.decorator'; +import { CommandGuard } from '../common/command.guard'; +import { QuizWizardService } from '../quiz-wizard/quiz-wizard.service'; +import { Fail, SubmitDto, Success } from './dto/submit.dto'; +import { + decryptObject, + encryptObject, + graphParser, + preview, +} from '../common/util'; +import { QuizGuard } from './quiz.guard'; +import { SessionUpdateInterceptor } from '../session/session-save.intercepter'; +import { + BadRequestResponseDto, + SharedDto, + isDecrypted, +} from './dto/shared.dto'; +import { GraphDto } from './dto/graph.dto'; @ApiTags('quizzes') @Controller('api/v1/quizzes') +@UseInterceptors(SessionUpdateInterceptor) export class QuizzesController { constructor( private readonly quizService: QuizzesService, private readonly sessionService: SessionService, private readonly containerService: ContainersService, + private readonly quizWizardService: QuizWizardService, @Inject('winston') private readonly logger: Logger, ) {} - @Get(':id') - @ApiOperation({ summary: 'ID를 통해 문제 정보를 가져올 수 있습니다.' }) - @ApiResponse({ - status: 200, - description: 'Returns the quiz details', - type: QuizDto, - }) - @ApiParam({ name: 'id', description: '문제 ID' }) - async getProblemById(@Param('id') id: number): Promise { - const quizDto = await this.quizService.getQuizById(id); - - return quizDto; - } - @Get('/') @ApiOperation({ summary: '카테고리 별로 모든 문제의 제목과 id를 가져올 수 있습니다.', @@ -66,34 +82,53 @@ export class QuizzesController { } @Post(':id/command') + @UseGuards(CommandGuard, QuizGuard) + @ApiNotFoundResponse({ + description: '해당 문제가 존재하지 않습니다.', + type: NotFoundResponseDto, + }) @ApiOperation({ summary: 'Git 명령을 실행합니다.' }) @ApiResponse({ status: 200, description: 'Git 명령의 실행 결과(stdout/stderr)를 리턴합니다.', type: CommandResponseDto, }) + @ApiForbiddenResponse({ + description: '금지된 명령이거나, editor를 연속으로 사용했을때', + type: ForbiddenResponseDto, + }) @ApiParam({ name: 'id', description: '문제 ID' }) @ApiBody({ description: 'Command to be executed', type: CommandRequestDto }) async runGitCommand( @Param('id') id: number, @Body() execCommandDto: CommandRequestDto, @Res() response: Response, - @Req() request: Request, + @SessionId() sessionId: string, ): Promise { try { - let sessionId = request.cookies?.sessionId; - + await this.sessionService.checkLogLength(sessionId, id); + } catch (e) { + this.logger.log('info', 'session not found. creating session..'); + response.cookie( + 'sessionId', + (sessionId = await this.sessionService.createSession()), + { + httpOnly: true, + }, + ); // 세션 아이디를 생성한다. + } + try { if (!sessionId) { // 세션 아이디가 없다면 + this.logger.log('info', 'no session id. creating session..'); response.cookie( 'sessionId', (sessionId = await this.sessionService.createSession()), { httpOnly: true, - // 개발 이후 활성화 시켜야함 - // secure: true, }, ); // 세션 아이디를 생성한다. + this.logger.log('info', `session id: ${sessionId} created`); } let containerId = await this.sessionService.getContainerIdBySessionId( @@ -101,7 +136,11 @@ export class QuizzesController { id, ); - if (!(await this.containerService.isValidateContainerId(containerId))) { + // 컨테이너가 없거나, 컨테이너가 유효하지 않다면 새로 생성한다. + if ( + !containerId || + !(await this.containerService.isValidateContainerId(containerId)) + ) { this.logger.log( 'info', 'no docker container or invalid container Id. creating container..', @@ -112,31 +151,86 @@ export class QuizzesController { id, containerId, ); + await this.containerService.restoreContainer( + await this.sessionService.getLogObject(sessionId, id), + ); } - this.logger.log( - 'info', - `running command "${execCommandDto.command}" for container ${containerId}`, - ); + // 리팩토링 필수입니다. + let message: string, result: string, graph: string, ref: string; - const { message, result } = await this.containerService.runGitCommand( - containerId, - execCommandDto.command, - ); + // command mode + if (execCommandDto.mode === MODE.COMMAND) { + this.logger.log( + 'info', + `running command "${execCommandDto.message}" for container ${containerId}`, + ); - this.sessionService.pushLogBySessionId( - execCommandDto.command, - sessionId, - id, - ); + ({ message, result, graph, ref } = + await this.containerService.runGitCommand( + containerId, + execCommandDto.message, + )); + } else if (execCommandDto.mode === MODE.EDITOR) { + // editor mode + const { mode: recentMode, message: recentMessage } = + await this.sessionService.getRecentLog(sessionId, id); + + // editor를 연속으로 사용했을 때 + if (recentMode === MODE.EDITOR) { + response.status(HttpStatus.FORBIDDEN).send({ + message: '편집기 명령 순서가 아닙니다', + error: 'Forbidden', + statusCode: 403, + }); + return; + } + + this.logger.log( + 'info', + `running editor command "${recentMessage}" for container ${containerId} with body starts with "${preview( + execCommandDto.message, + )}"`, + ); + + ({ message, result, graph, ref } = + await this.containerService.runEditorCommand( + containerId, + recentMessage, + execCommandDto.message, + )); + } else { + response.status(HttpStatus.BAD_REQUEST).send({ + message: '잘못된 요청입니다.', + }); + } + + // message를 저장합니다. + this.sessionService.pushLogBySessionId(execCommandDto, sessionId, id); + this.sessionService.updateRef(sessionId, id, ref); + + if ( + result !== MODE.EDITOR && + (await this.sessionService.isGraphUpdated(sessionId, id, graph)) + ) { + await this.sessionService.updateGraph(sessionId, id, graph); + response.status(HttpStatus.OK).send({ + message, + result, + graph: graphParser(graph), + ref, + }); + } else { + response.status(HttpStatus.OK).send({ + message, + result, + ref, + }); + } - response.status(HttpStatus.OK).send({ - message, - result, - // graph: 필요한 경우 여기에 추가 - }); return; } catch (error) { + this.logger.log('error', error); throw new HttpException( { message: 'Internal Server Error', @@ -148,6 +242,11 @@ export class QuizzesController { } @Delete(':id/command') + @UseGuards(QuizGuard) + @ApiNotFoundResponse({ + description: '해당 문제가 존재하지 않습니다.', + type: NotFoundResponseDto, + }) @ApiOperation({ summary: 'Git 명령기록과, 할당된 컨테이너를 삭제합니다' }) @ApiResponse({ status: 200, @@ -157,15 +256,11 @@ export class QuizzesController { @ApiParam({ name: 'id', description: '문제 ID' }) async deleteCommandHistory( @Param('id') id: number, - @Req() request: Request, + @SessionId() sessionId: string, ): Promise { - try { - const sessionId = request.cookies?.sessionId; - - if (!sessionId) { - return; - } + if (!sessionId) return; + try { const containerId = await this.sessionService.getContainerIdBySessionId( sessionId, id, @@ -177,7 +272,7 @@ export class QuizzesController { this.containerService.deleteContainer(containerId); - this.sessionService.deleteCommandHistory(sessionId, id); + await this.sessionService.deleteCommandHistory(sessionId, id); } catch (e) { throw new HttpException( { @@ -188,4 +283,205 @@ export class QuizzesController { ); } } + + @Post(':id/submit') + @HttpCode(HttpStatus.OK) + @UseGuards(QuizGuard) + @ApiNotFoundResponse({ + description: '해당 문제가 존재하지 않습니다.', + type: NotFoundResponseDto, + }) + @ApiOperation({ summary: '채점을 요청합니다.' }) + @ApiResponse({ + status: 200, + description: '채점 결과를 리턴합니다.', + type: Success, + }) + @ApiParam({ name: 'id', description: '문제 ID' }) + async submit( + @Param('id') id: number, + @SessionId() sessionId: string, + ): Promise { + if (!sessionId) return new Fail(); + try { + let containerId = await this.sessionService.getContainerIdBySessionId( + sessionId, + id, + ); + + if ( + !containerId || + !(await this.containerService.isValidateContainerId(containerId)) + ) { + // 재현해서 컨테이너 발급하기 + this.logger.log( + 'info', + 'no docker container or invalid container Id. creating container..', + ); + containerId = await this.containerService.getContainer(id); + await this.sessionService.setContainerBySessionId( + sessionId, + id, + containerId, + ); + await this.containerService.restoreContainer( + await this.sessionService.getLogObject(sessionId, id), + ); + } + + const result: boolean = await this.quizWizardService.submit( + containerId, + id, + ); + + if (!result) { + await this.sessionService.setQuizSolving(sessionId, id); + return new Fail(); + } + + await this.sessionService.setQuizSolved(sessionId, id); + + const commands = ( + await this.sessionService.getLogObject(sessionId, id) + ).logs + .filter((log) => log.mode === 'command') + .map((log) => { + if (log.message.startsWith('git')) { + return log.message.replace('git', '`git`'); + } + return log.message; + }); + const encodedCommands = encryptObject({ + id, + commands, + }); + + return new Success(encodedCommands); + } catch (e) { + this.logger.log('error', e); + throw new HttpException( + { + message: 'Internal Server Error', + result: 'fail', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @Get('shared') + @HttpCode(HttpStatus.OK) + @ApiBadRequestResponse({ + description: + '제공된 암호화된 문자열이 유효하지 않거나, 복호화에 실패했습니다.', + type: BadRequestResponseDto, + }) + @ApiOperation({ summary: '링크를 통해 공유받은 정답을 확인합니다.' }) + @ApiResponse({ + status: 200, + description: '문제와 문제의 정답을 리턴합니다.', + type: SharedDto, + }) + @ApiQuery({ + name: 'answer', + required: true, + description: '공유받은 암호화된 문자열', + type: String, + }) + async shared(@Query('answer') answer: string): Promise { + try { + const decrypted = decryptObject(answer); + + if (!isDecrypted(decrypted)) { + throw new HttpException( + '공유된 문제가 올바르지 않습니다.', + HttpStatus.BAD_REQUEST, + ); + } + + const quizDto = await this.quizService.getQuizById( + parseInt(decrypted.id, 10), + ); + return new SharedDto(decrypted.commands, quizDto); + } catch (e) { + this.logger.log('error', e); + throw new HttpException( + { + message: '제공된 암호화된 문자열이 유효하지 않습니다.', + result: 'fail', + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + @Get(':id') + @UseGuards(QuizGuard) + @ApiNotFoundResponse({ + description: '해당 문제가 존재하지 않습니다.', + type: NotFoundResponseDto, + }) + @ApiOperation({ summary: 'ID를 통해 문제 정보를 가져올 수 있습니다.' }) + @ApiResponse({ + status: 200, + description: 'Returns the quiz details', + type: QuizDto, + }) + @ApiParam({ name: 'id', description: '문제 ID' }) + async getProblemById(@Param('id') id: number): Promise { + const quizDto = await this.quizService.getQuizById(id); + + return quizDto; + } + + @Get('/:id/graph') + @UseGuards(QuizGuard) + @ApiNotFoundResponse({ + description: '해당 문제가 존재하지 않습니다.', + type: NotFoundResponseDto, + }) + @ApiOperation({ + summary: 'ID를 통해 문제의 그래프 정보를 가져올 수 있습니다.', + }) + @ApiResponse({ + status: 200, + description: 'Returns the graph details', + type: GraphDto, + }) + async getGraphById( + @Param('id') id: number, + @SessionId() sessionId: string, + ): Promise { + const defaultRef = await this.quizService.getRefById(id); + const defaultGraph = JSON.parse(await this.quizService.getGraphById(id)); + if (!sessionId) { + return { + ...defaultGraph, + ref: defaultRef, + }; + } + let graph: string; + let ref: string; + try { + graph = await this.sessionService.getGraphById(sessionId, id); + ref = await this.sessionService.getRefById(sessionId, id); + } catch (e) { + return { + ...defaultGraph, + ref: defaultRef, + }; + } + if (await this.sessionService.isReseted(sessionId, id)) { + return { + ...defaultGraph, + ref: defaultRef, + }; + } else { + const parsedGraph = graphParser(graph); + return { + graph: parsedGraph, + ref, + }; + } + } } diff --git a/packages/backend/src/quizzes/quizzes.module.ts b/packages/backend/src/quizzes/quizzes.module.ts index 6672409..139208b 100644 --- a/packages/backend/src/quizzes/quizzes.module.ts +++ b/packages/backend/src/quizzes/quizzes.module.ts @@ -7,12 +7,14 @@ import { Category } from './entity/category.entity'; import { ContainersModule } from '../containers/containers.module'; import { SessionModule } from '../session/session.module'; import { Keyword } from './entity/keyword.entity'; +import { QuizWizardModule } from '../quiz-wizard/quiz-wizard.module'; @Module({ imports: [ TypeOrmModule.forFeature([Quiz, Category, Keyword]), ContainersModule, SessionModule, + QuizWizardModule, ], controllers: [QuizzesController], providers: [QuizzesService], diff --git a/packages/backend/src/quizzes/quizzes.service.ts b/packages/backend/src/quizzes/quizzes.service.ts index f61c933..d25b43e 100644 --- a/packages/backend/src/quizzes/quizzes.service.ts +++ b/packages/backend/src/quizzes/quizzes.service.ts @@ -1,16 +1,15 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Quiz } from './entity/quiz.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { QuizDto } from './dto/quiz.dto'; import { CategoryQuizzesDto, QuizzesDto } from './dto/quizzes.dto'; import { Category } from './entity/category.entity'; -import { ContainersService } from 'src/containers/containers.service'; -import { CommandResponseDto } from './dto/command-response.dto'; import fs from 'fs'; import * as Papa from 'papaparse'; import { Keyword } from './entity/keyword.entity'; import { Logger } from 'winston'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class QuizzesService { @@ -21,10 +20,12 @@ export class QuizzesService { private categoryRepository: Repository, @InjectRepository(Keyword) private keywordRepository: Repository, - private containerService: ContainersService, + private configService: ConfigService, @Inject('winston') private readonly logger: Logger, ) { - this.initQiuzzes(); + if (configService.get('NODE_ENV') !== 'test') { + this.initQiuzzes(); + } } private async initQiuzzes() { @@ -66,6 +67,9 @@ export class QuizzesService { quiz.description = data.description; quiz.category = categories[data.category]; quiz.id = data.id; + quiz.answer = data.answer || ''; + quiz.graph = data.graph || ''; + quiz.ref = data.ref || ''; const keywordList = data.keyword.split(',').map((kw) => kw.trim()); quiz.keywords = keywordList.map((kw) => keywords[kw]); @@ -94,22 +98,22 @@ export class QuizzesService { }); }); } + async isQuizExist(id: number): Promise { + return await this.quizRepository.exist({ where: { id } }); + } async getQuizById(id: number): Promise { const quiz = await this.quizRepository.findOne({ where: { id }, relations: ['keywords', 'category'], }); - if (!quiz) { - throw new NotFoundException(`Quiz ${id} not found`); - } - const quizDto: QuizDto = { id: quiz.id, title: quiz.title, description: quiz.description, keywords: quiz.keywords.map((keyword) => keyword.keyword), category: quiz.category.name, + answer: quiz.answer.split('\\n'), }; return quizDto; @@ -138,15 +142,19 @@ export class QuizzesService { return quizzesDtos; } - async runGitCommand(command: string): Promise { - // 세션 검색 + async getGraphById(id: number): Promise { + const quiz = await this.quizRepository.findOne({ + where: { id }, + }); - // 세션 없으면 or 세션에 할당된 컨테이너 없으면 컨테이너 생성 - // await this.containerService.getContainer(quizId); + return quiz.graph; + } - // 컨테이너 생성, 세션에 할당하고 DB 저장 + async getRefById(id: number): Promise { + const quiz = await this.quizRepository.findOne({ + where: { id }, + }); - // 최종 실행 - return this.containerService.runGitCommand('testContainer', command); + return quiz.ref; } } diff --git a/packages/backend/src/session/dto/solved.dto.ts b/packages/backend/src/session/dto/solved.dto.ts new file mode 100644 index 0000000..04d9e69 --- /dev/null +++ b/packages/backend/src/session/dto/solved.dto.ts @@ -0,0 +1,10 @@ +const QUIZ_COUNT = 19; +export class SolvedDto { + [key: number]: boolean; + + constructor() { + for (let i = 1; i <= QUIZ_COUNT; i++) { + this[i] = false; + } + } +} diff --git a/packages/backend/src/session/schema/session.schema.ts b/packages/backend/src/session/schema/session.schema.ts index 55451f7..894e08e 100644 --- a/packages/backend/src/session/schema/session.schema.ts +++ b/packages/backend/src/session/schema/session.schema.ts @@ -1,14 +1,15 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; +const Action = { + Command: 'command', + Editor: 'editor', +} as const; + +export type ActionType = (typeof Action)[keyof typeof Action]; + @Schema({ timestamps: true }) export class Session extends Document { - // @Prop({ required: true }) - // createdAt: Date; - // - // @Prop({ required: true }) - // updatedAt: Date; - @Prop() deletedAt: Date | null; @@ -17,16 +18,31 @@ export class Session extends Document { type: Map, of: { status: { type: String, required: true }, - logs: { type: [String], required: true }, + logs: { + type: [ + { + mode: { type: String, enum: Object.values(Action), required: true }, + message: { type: String, required: true }, + }, + ], + required: true, + }, containerId: { type: String, default: '' }, + graph: { type: String, default: '' }, + ref: { type: String, default: '' }, }, }) problems: Map< number, { status: string; - logs: string[]; + logs: { + mode: ActionType; + message: string; + }[]; containerId: string; + graph: string; + ref: string; } >; } diff --git a/packages/backend/src/session/session-save.intercepter.ts b/packages/backend/src/session/session-save.intercepter.ts new file mode 100644 index 0000000..6d4d12f --- /dev/null +++ b/packages/backend/src/session/session-save.intercepter.ts @@ -0,0 +1,35 @@ +import { tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { SessionService } from './session.service'; +import { Response } from 'express'; + +@Injectable() +export class SessionUpdateInterceptor implements NestInterceptor { + constructor(private sessionService: SessionService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response: Response = context.switchToHttp().getResponse(); + let sessionId = request.cookies.sessionId; // 세션 ID 추출 + + return next.handle().pipe( + tap(() => { + sessionId = + this.extractSessionId(response.getHeader('Set-Cookie')) || sessionId; // 세션 ID가 없으면 쿠키에서 추출 + // 세션 업데이트 로직 + this.sessionService.saveSession(sessionId); + }), + ); + } + + private extractSessionId(cookieStr) { + const sessionIdMatch = /sessionId=([^;]+)/.exec(cookieStr); + return sessionIdMatch ? sessionIdMatch[1] : null; + } +} diff --git a/packages/backend/src/session/session.controller.spec.ts b/packages/backend/src/session/session.controller.spec.ts new file mode 100644 index 0000000..59e2f77 --- /dev/null +++ b/packages/backend/src/session/session.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SessionController } from './session.controller'; + +describe('SessionController', () => { + let controller: SessionController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SessionController], + }).compile(); + + controller = module.get(SessionController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/backend/src/session/session.controller.ts b/packages/backend/src/session/session.controller.ts new file mode 100644 index 0000000..7cdc3d0 --- /dev/null +++ b/packages/backend/src/session/session.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Delete, Get, Res, UseInterceptors } from '@nestjs/common'; +import { SessionService } from './session.service'; +import { SessionId } from './session.decorator'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { SessionUpdateInterceptor } from './session-save.intercepter'; +import { SolvedDto } from './dto/solved.dto'; + +@ApiTags('session') +@Controller('api/v1/session') +@UseInterceptors(SessionUpdateInterceptor) +export class SessionController { + constructor(private readonly sessionService: SessionService) {} + + @Delete() + @ApiOperation({ summary: '세션을 삭제합니다.' }) + @ApiResponse({ + status: 200, + description: '세션을 삭제합니다.', + }) + async deleteSession( + @SessionId() sessionId: string, + @Res() response: Response, + ) { + if (!sessionId) { + response.end(); + return; + } + + response.clearCookie('sessionId'); + this.sessionService.deleteSession(sessionId); + response.end(); + return; + } + + @Get('/solved') + @ApiOperation({ summary: '해결한 문제들을 알려줍니다' }) + @ApiResponse({ + status: 200, + description: '해결한 문제들을 알려줍니다', + type: SolvedDto, + }) + async getSolvedProblems(@SessionId() sessionId: string): Promise { + if (!sessionId) { + return new SolvedDto(); + } + + return await this.sessionService.getSolvedProblems(sessionId); + } +} diff --git a/packages/backend/src/session/session.decorator.ts b/packages/backend/src/session/session.decorator.ts new file mode 100644 index 0000000..f23d275 --- /dev/null +++ b/packages/backend/src/session/session.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const SessionId = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.cookies['sessionId']; + }, +); diff --git a/packages/backend/src/session/session.module.ts b/packages/backend/src/session/session.module.ts index ccd2a28..74422cb 100644 --- a/packages/backend/src/session/session.module.ts +++ b/packages/backend/src/session/session.module.ts @@ -3,6 +3,7 @@ import { SessionService } from './session.service'; import { MongooseModule } from '@nestjs/mongoose'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { Session, SessionSchema } from './schema/session.schema'; +import { SessionController } from './session.controller'; @Module({ imports: [ @@ -17,5 +18,6 @@ import { Session, SessionSchema } from './schema/session.schema'; ], providers: [SessionService], exports: [SessionService], + controllers: [SessionController], }) export class SessionModule {} diff --git a/packages/backend/src/session/session.service.ts b/packages/backend/src/session/session.service.ts index 7f78be1..94b7408 100644 --- a/packages/backend/src/session/session.service.ts +++ b/packages/backend/src/session/session.service.ts @@ -1,21 +1,18 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Logger } from 'winston'; import { Model } from 'mongoose'; -import { Session } from './schema/session.schema'; +import { ActionType, Session } from './schema/session.schema'; import { ObjectId } from 'typeorm'; +import { SolvedDto } from './dto/solved.dto'; @Injectable() export class SessionService { + private readonly sessionMap: Map = new Map(); constructor( @InjectModel(Session.name) private sessionModel: Model, @Inject('winston') private readonly logger: Logger, - ) { - const testSession = new this.sessionModel({ - problems: {}, - }); - testSession.save(); - } + ) {} async createSession(): Promise { const session = new this.sessionModel({ @@ -23,7 +20,9 @@ export class SessionService { }); return await session.save().then((session) => { this.logger.log('info', `session ${session._id as ObjectId} created`); - return (session._id as ObjectId).toString('hex'); + const sessionId = (session._id as ObjectId).toString('hex'); + this.sessionMap.set(sessionId, session); + return sessionId; }); } @@ -38,13 +37,14 @@ export class SessionService { status: 'solving', logs: [], containerId: '', + graph: '', + ref: '', }); this.logger.log('info', `session ${session._id as ObjectId} updated`); this.logger.log( 'info', `session's new quizId: ${problemId}, document created`, ); - await session.save(); } else { this.logger.log( 'info', @@ -68,11 +68,24 @@ export class SessionService { `setting ${sessionId}'s containerId as ${containerId}`, ); session.problems.get(problemId).containerId = containerId; - session.save(); + } + + async getRecentLog( + sessionId: string, + problemId: number, + ): Promise<{ mode: string; message: string }> { + const session = await this.getSessionById(sessionId); + + const problemLogs = session?.problems.get(problemId)?.logs; + if (!problemLogs || problemLogs.length === 0) { + throw new Error('No execution record(이전에 명령을 실행한 적 없습니다.)'); + } + + return problemLogs[problemLogs.length - 1]; } async pushLogBySessionId( - command: string, + log: { mode: ActionType; message: string }, sessionId: string, problemId: number, ): Promise { @@ -80,8 +93,7 @@ export class SessionService { if (!session.problems.get(problemId)) { throw new Error('problem not found'); } - session.problems.get(problemId).logs.push(command); - session.save(); + session.problems.get(problemId).logs.push(log); } async deleteCommandHistory( @@ -93,11 +105,143 @@ export class SessionService { throw new Error('problem not found'); } session.problems.get(problemId).logs = []; + session.problems.get(problemId).status = 'solving'; + session.problems.get(problemId).graph = ''; session.problems.get(problemId).containerId = ''; - session.save(); + session.problems.get(problemId).ref = ''; } private async getSessionById(id: string): Promise { - return await this.sessionModel.findById(id); + let session = this.sessionMap.get(id); + if (!session) { + session = await this.sessionModel.findById(id); + if (!session) { + throw new Error('session not found'); + } + this.sessionMap.set(id, session); + } + return session; + } + + async saveSession(sessionId: string): Promise { + // 세션 조회 및 저장 로직 + const session = this.sessionMap.get(sessionId); + if (session) { + this.sessionMap.delete(sessionId); + await session.save(); + } + } + + async deleteSession(sessionId: string): Promise { + //soft delete + const session = await this.getSessionById(sessionId); + session.deletedAt = new Date(); + this.logger.log('info', `session ${session._id as ObjectId} deleted`); + } + + async getLogObject(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + return session.problems.get(problemId); + } + + async setQuizSolved(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).status = 'solved'; + } + + async setQuizSolving(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).status = 'solving'; + } + + async getSolvedProblems(sessionId: string): Promise { + const session = await this.getSessionById(sessionId); + const solvedDto = new SolvedDto(); + session.problems.forEach((value, key) => { + if (value.status === 'solved') { + solvedDto[key] = true; + } + }); + return solvedDto; + } + + async getGraphById(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + return session.problems.get(problemId)?.graph; + } + + async getRefById(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + return session.problems.get(problemId)?.ref; + } + + async isGraphUpdated( + sessionId: string, + problemId: number, + graph: string, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + return session.problems.get(problemId).graph !== graph; + } + + async updateGraph( + sessionId: string, + problemId: number, + graph: string, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).graph = graph; + } + + async updateRef( + sessionId: string, + problemId: number, + ref: string, + ): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + throw new Error('problem not found'); + } + session.problems.get(problemId).ref = ref; + } + + async checkLogLength(sessionId: string, problemId: number): Promise { + const MAX_LOG_LENGTH = 100; + if (!sessionId) { + return; + } + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + return; + } + if (session.problems.get(problemId).logs.length > MAX_LOG_LENGTH) { + throw new ForbiddenException( + 'Too many commands(너무 많은 명령어를 입력했습니다.)', + ); + } + return; + } + + async isReseted(sessionId: string, problemId: number): Promise { + const session = await this.getSessionById(sessionId); + if (!session.problems.get(problemId)) { + return true; + } + return session.problems.get(problemId).logs.length === 0; } } diff --git a/packages/backend/test/api.e2e-spec.ts b/packages/backend/test/api.e2e-spec.ts new file mode 100644 index 0000000..658b1f1 --- /dev/null +++ b/packages/backend/test/api.e2e-spec.ts @@ -0,0 +1,157 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import { AppModule } from '../src/app.module'; + +describe('QuizWizardController (e2e)', () => { + let app: INestApplication; + let response; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + app.use(cookieParser()); + + await app.init(); + }); + + describe('GET shared?answer=""', () => { + it('200', async () => { + response = await request(app.getHttpServer()) + .get( + `/api/v1/quizzes/shared?answer=644c3dc58a933571fd27ef95579b713c:618074da8a6d55a6b2b29e955942c887988ea264ab23ce6e99ef9fb09dbfc11ae6ca2c1c3e77c73370f6cc83d47600aaf6d702db3ad208febe6d81be7e9e4d30`, + ) + .expect(200); + + expect(response.body.answer).toEqual(['git add .', 'git commit']); + expect(response.body.quiz.id).toEqual(4); + }); + + it('400 잘못된 문자열', async () => { + response = await request(app.getHttpServer()) + .get(`/api/v1/quizzes/shared?answer=""`) + .expect(400); + }); + + it('400 복호화 했는데 이상함', async () => { + response = await request(app.getHttpServer()) + .get(`/api/v1/quizzes/shared?answer=""`) + .expect(400); + }); + }); + + describe('ref 동작 테스트', () => { + let cookie: string; + + it('최초 요청: 빈 문자열', async () => { + response = await request(app.getHttpServer()).get( + `/api/v1/quizzes/1/graph`, + ); + + expect(response.body.ref).toEqual(''); + }); + + it('최초 요청: 브랜치', async () => { + response = await request(app.getHttpServer()).get( + `/api/v1/quizzes/9/graph`, + ); + + expect(response.body.ref).toEqual('feat/somethingB'); + }); + + it('빈 문자열', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/1/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + expect(response.body.ref).toEqual(''); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/1/command`) + .set('Cookie', cookie); + }); + + it('브랜치명', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/4/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + expect(response.body.ref).toEqual('main'); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/4/command`) + .set('Cookie', cookie); + }); + + it('커밋 해시', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/9/command`) + .send({ + mode: 'command', + message: 'git checkout 55f7aab2f31287f6bb159731e5824566c02b582b', + }); + + expect(response.body.ref).toEqual('55f7aab'); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/9/command`) + .set('Cookie', cookie); + }); + + it('editor 명령', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/9/command`) + .send({ + mode: 'command', + message: 'git add .', + }); + + expect(response.body.ref).toEqual('feat/somethingB'); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/9/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/9/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: 'test', + }); + + expect(response.body.ref).toEqual('feat/somethingB'); + + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/9/command`) + .set('Cookie', cookie); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/packages/backend/test/app.e2e-spec.ts b/packages/backend/test/app.e2e-spec.ts index 50cda62..d60357b 100644 --- a/packages/backend/test/app.e2e-spec.ts +++ b/packages/backend/test/app.e2e-spec.ts @@ -1,24 +1,1668 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import { AppModule } from '../src/app.module'; -describe('AppController (e2e)', () => { +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('QuizWizardController (e2e)', () => { let app: INestApplication; + let response; + let cookie: string; - beforeEach(async () => { + beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); + + app.use(cookieParser()); + await app.init(); + + await sleep(5000); + }); + + describe('1번 문제 채점 테스트', () => { + const id = 1; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git init', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('아무 것도 하지 않았다면', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('2번 문제 채점 테스트', () => { + const id = 2; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git config user.name GitChallenge', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git config user.email gitchallenge@mergemaster.com', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('이름만 바꾼 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git config user.name GitChallenge', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('3번 문제 채점 테스트', () => { + const id = 3; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add README.md docs/plan.md', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('전부 add한 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add .', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('4번 문제 채점 테스트', () => { + const id = 4; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add .', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "test commit"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('에디터 이용 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add .', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: 'test commit', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('커밋하지 않은 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('add하지 않은 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git commit -m "test commit"', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('5번 문제 채점 테스트', () => { + const id = 5; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git switch -c dev', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('브랜치명 오타 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git branch devv', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('6번 문제 채점 테스트', () => { + const id = 6; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add .', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "test commit"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch main', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('stash하고 메인 이동', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git stash', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch main', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + describe('7번 문제 채점 테스트', () => { + const id = 7; + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit --amend', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: '회원가입 기능 구현', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('마지막에 브랜치만 switch', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit --amend', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: '회원가입 기능 구현', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch feat/somethingA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('amend하는 커밋 메시지 오타', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit --amend', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: '회원가입 기능 구현ㄴ', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('아무 것도 안 하고 채점', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('8번 문제 채점 테스트', () => { + const id = 8; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git reset HEAD~1', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "회원가입 기능 구현"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "회원가입 테스트 코드 작성"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('에디터 적극 사용 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git reset HEAD~1', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: '회원가입 기능 구현', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: '회원가입 테스트 코드 작성', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('add, commit 순서 반대로', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git reset HEAD~1', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.test.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "회원가입 테스트 코드 작성"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git add signup.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git commit -m "회원가입 기능 구현"', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('아무 것도 안 하고 채점', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('9번 문제 채점 테스트', () => { + const id = 9; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git restore important.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('아무 것도 안 하고 채점', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('10번 문제 채점 테스트', () => { + const id = 10; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git clean -f tmp.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git clean -f tmptmp.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git clean -f tmptmptmp.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('tmptmptmp.js는 안 지움', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git clean -f tmp.js', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git clean -f tmptmp.js', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('11번 문제 채점 테스트', () => { + const id = 11; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git stash', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git checkout 55f7aab2f31287f6bb159731e5824566c02b582b', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch -c hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('체크아웃 한 다음에 stash', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git checkout 55f7aab2f31287f6bb159731e5824566c02b582b', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git stash', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch -c hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('브랜치 만들 때 실수하는 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git stash', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git checkout 55f7aab2f31287f6bb159731e5824566c02b582b', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('아무것도 안 하고 채점', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git status', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('12번 문제 채점 테스트', () => { + const id = 12; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git switch hotfix/fixA', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git cherry-pick 38bafc899bb9257644ac616ac0075431d8481e83', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch main', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git merge hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch feat/somethingB', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git reset --hard HEAD^', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', true); + }); + + it('somethingB reset 안 함', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git switch hotfix/fixA', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git cherry-pick 38bafc899bb9257644ac616ac0075431d8481e83', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch main', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git merge hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('main merge 안 함', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git switch hotfix/fixA', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git cherry-pick 38bafc899bb9257644ac616ac0075431d8481e83', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch feat/somethingB', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git reset --hard HEAD^', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + + it('cherry-pick 실수 (다른 커밋에다 함)', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git switch hotfix/fixA', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git cherry-pick ee0765a0f4bd5df20a595100b62c041100d64096', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git merge hotfix/fixA', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git switch feat/somethingB', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'command', + message: 'git reset --hard HEAD^', + }); + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/submit`) + .set('Cookie', cookie) + .expect(200); + + expect(response.body).toHaveProperty('solved', false); + }); + }); + + describe('13번 문제 채점 테스트', () => { + const id = 13; + afterEach(async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie); + }); + + it('베스트 성공 케이스', async () => { + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .send({ + mode: 'command', + message: 'git rebase -i c01511a8d88b4355d754406ef6ec20aa49d69c5b', + }); + + cookie = response.headers['set-cookie'][0].split(';')[0]; + + response = await request(app.getHttpServer()) + .post(`/api/v1/quizzes/${id}/command`) + .set('Cookie', cookie) + .send({ + mode: 'editor', + message: `reword 6a83279 로그인 기느 ㄱ후ㅕㄴ\npick 09dca00 로그인 테스트 코드 작성\npick d109f51 회원탈퇴 기능 구현\n\n# Rebase c01511a..d109f51 onto c01511a (3 commands)\n#\n# Commands:\n# p, pick = use commit\n# r, reword = use commit, but edit the commit message\n# e, edit = use commit, but stop for amending\n# s, squash = use commit, but meld into previous commit\n# f, fixup [-C | -c] = like \"squash\" but keep only the previous\n# commit's log message, unless -C is used, in which case\n# keep only this commit's message; -c is same as -C but\n# opens the editor\n# x, exec = run command (the rest of the line) using shell\n# b, break = stop here (continue rebase later with 'git rebase --continue')\n# d, drop = remove commit\n# l, label