diff --git a/.github/workflows/backend_cd.yml b/.github/workflows/backend_cd.yml index 72bf42d4..cf758936 100644 --- a/.github/workflows/backend_cd.yml +++ b/.github/workflows/backend_cd.yml @@ -8,17 +8,20 @@ on: paths: - backend/** -env: - ARTIFACT_NAME: develup-0.0.1-SNAPSHOT - ARTIFACT_DIRECTORY: ./backend/build/libs - jobs: build: - name: πŸ—οΈ Build Jar and Upload Artifact + name: πŸ—οΈ Build Jar and Upload Docker Image runs-on: ubuntu-latest + defaults: + run: + working-directory: backend steps: - uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.SUBMODULE_GITHUB_TOKEN }} + - name: πŸ—οΈ Set up JDK 21 uses: actions/setup-java@v4 with: @@ -29,47 +32,49 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: πŸ—οΈ Build with Gradle - run: | - cd backend - ./gradlew clean bootJar + run: ./gradlew clean bootJar + + - name: 🐳 Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: πŸ“€ Upload Artifact File - uses: actions/upload-artifact@v4 + - name: 🐳 Docker Image Build and Push + uses: docker/build-push-action@v6 with: - name: ${{ env.ARTIFACT_NAME }} - path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + context: ./backend + push: true + tags: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }} + platforms: linux/arm64 deploy: name: πŸš€ Server Deployment needs: build - runs-on: [ self-hosted,develup ] + runs-on: [ self-hosted, develup ] + defaults: + run: + working-directory: backend + + env: + BACKEND_APP_IMAGE_NAME: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }} steps: - - name: πŸ“₯ Download Artifact File - uses: actions/download-artifact@v4 + - uses: actions/checkout@v4 + - name: 🐳 Login to Docker Hub + uses: docker/login-action@v3 with: - name: ${{ env.ARTIFACT_NAME }} - path: ${{ env.ARTIFACT_DIRECTORY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: πŸ”΄ Stop Server - run: | - PID=$(sudo lsof -t -i:8080 || true) - if [ -n "$PID" ]; then - sudo kill -15 $PID - tail --pid=$PID -f /dev/null - echo "Server has been stopped. PID: $PID" - else - echo "No server is running." - fi - - - name: 🟒 Start Server - run: | - sudo nohup java \ - -Dauth.github.client-id=${{ secrets.CLIENT_ID_GITHUB }} \ - -Dauth.github.client-secret=${{ secrets.CLIENT_SECRET_GITHUB }} \ - -Dspring.profiles.active=dev \ - -Dserver.port=8080 \ - -jar ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar & + - name: 🐳 Pull Docker Image + run: docker pull ${{ env.BACKEND_APP_IMAGE_NAME }} + + - name: 🐳 Docker Compose up + run: docker compose -f compose.yml up -d + + - name: 🐳 Clean Unused Image + run: docker image prune -af slack-notify_success: runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..77ebe526 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend/secrets"] + path = backend/secrets + url = https://github.com/woowacourse-teams/2024-devel-up-secret.git diff --git a/backend/.gitignore b/backend/.gitignore index c2065bc2..1d5661ce 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -33,5 +33,8 @@ out/ /nbdist/ /.nb-gradle/ +### submoduleyml ### +src/main/resources/application*.yml + ### VS Code ### .vscode/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..00c8a241 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:21 + +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILE}", \ + "-jar", "/app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle index f273328c..0672379d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -48,3 +48,11 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +processResources.dependsOn('copySecret') + +tasks.register('copySecret', Copy) { + from './secrets' + include "application*.yml" + into './src/main/resources' +} diff --git a/backend/compose.yml b/backend/compose.yml new file mode 100644 index 00000000..35b50b89 --- /dev/null +++ b/backend/compose.yml @@ -0,0 +1,30 @@ +services: + nginx: + image: nginx + depends_on: + - application + networks: + - nginx-app-net + ports: + - "80:80" + - "443:443" + volumes: + - /home/ubuntu/custom.conf:/etc/nginx/conf.d/default.conf + - /etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem:/etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem + - /etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem:/etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem + + application: + image: ${BACKEND_APP_IMAGE_NAME} + networks: + - nginx-app-net + ports: + - "8080:8080" + - "8082:8082" + environment: + TZ: "Asia/Seoul" + SPRING_PROFILE: dev + restart: always + container_name: develup-app + +networks: + nginx-app-net: diff --git a/backend/secrets b/backend/secrets new file mode 160000 index 00000000..8ad132f7 --- /dev/null +++ b/backend/secrets @@ -0,0 +1 @@ +Subproject commit 8ad132f73273f382bf81c26505565780266ed303 diff --git a/backend/src/main/java/develup/api/HashTagApi.java b/backend/src/main/java/develup/api/HashTagApi.java new file mode 100644 index 00000000..8d48db3b --- /dev/null +++ b/backend/src/main/java/develup/api/HashTagApi.java @@ -0,0 +1,30 @@ +package develup.api; + +import java.util.List; +import develup.api.common.ApiResponse; +import develup.application.hashtag.HashTagResponse; +import develup.application.hashtag.HashTagService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "ν•΄μ‹œνƒœκ·Έ API") +class HashTagApi { + + private final HashTagService hashTagService; + + public HashTagApi(HashTagService hashTagService) { + this.hashTagService = hashTagService; + } + + @GetMapping("/hash-tags") + @Operation(summary = "ν•΄μ‹œνƒœκ·Έ λͺ©λ‘ 쑰회 API", description = "ν•΄μ‹œνƒœκ·Έ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") + public ResponseEntity>> getHashTags() { + List responses = hashTagService.getHashTags(); + + return ResponseEntity.ok(new ApiResponse<>(responses)); + } +} diff --git a/backend/src/main/java/develup/api/MissionApi.java b/backend/src/main/java/develup/api/MissionApi.java index c0df343f..67e96adc 100644 --- a/backend/src/main/java/develup/api/MissionApi.java +++ b/backend/src/main/java/develup/api/MissionApi.java @@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -26,8 +27,10 @@ public MissionApi(MissionService missionService) { @GetMapping("/missions") @Operation(summary = "λ―Έμ…˜ λͺ©λ‘ 쑰회 API", description = "λ―Έμ…˜ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") - public ResponseEntity>> getMissions() { - List responses = missionService.getMissions(); + public ResponseEntity>> getMissions( + @RequestParam(defaultValue = "all") String hashTag + ) { + List responses = missionService.getMissions(hashTag); return ResponseEntity.ok(new ApiResponse<>(responses)); } diff --git a/backend/src/main/java/develup/api/SolutionApi.java b/backend/src/main/java/develup/api/SolutionApi.java index 6ebdcb78..f76fcfef 100644 --- a/backend/src/main/java/develup/api/SolutionApi.java +++ b/backend/src/main/java/develup/api/SolutionApi.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -53,9 +54,11 @@ public ResponseEntity> submitSolution( } @GetMapping("/solutions") - @Operation(summary = "μ†”λ£¨μ…˜ 쑰회 λͺ©λ‘ API", description = "μ†”λ£¨μ…˜ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") - public ResponseEntity>> getSolutions() { - List responses = solutionService.getCompletedSummaries(); + @Operation(summary = "μ†”λ£¨μ…˜ λͺ©λ‘ 쑰회 API", description = "μ†”λ£¨μ…˜ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.") + public ResponseEntity>> getSolutions( + @RequestParam(defaultValue = "all") String hashTag + ) { + List responses = solutionService.getCompletedSummaries(hashTag); return ResponseEntity.ok(new ApiResponse<>(responses)); } diff --git a/backend/src/main/java/develup/api/SolutionCommentApi.java b/backend/src/main/java/develup/api/SolutionCommentApi.java index 837e378e..500c4931 100644 --- a/backend/src/main/java/develup/api/SolutionCommentApi.java +++ b/backend/src/main/java/develup/api/SolutionCommentApi.java @@ -6,9 +6,9 @@ import develup.api.common.ApiResponse; import develup.application.auth.Accessor; import develup.application.solution.comment.CreateSolutionCommentResponse; +import develup.application.solution.comment.SolutionCommentRepliesResponse; import develup.application.solution.comment.SolutionCommentRequest; import develup.application.solution.comment.SolutionCommentService; -import develup.application.solution.comment.SolutionCommentRepliesResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; diff --git a/backend/src/main/java/develup/application/hashtag/HashTagService.java b/backend/src/main/java/develup/application/hashtag/HashTagService.java new file mode 100644 index 00000000..22764c76 --- /dev/null +++ b/backend/src/main/java/develup/application/hashtag/HashTagService.java @@ -0,0 +1,21 @@ +package develup.application.hashtag; + +import java.util.List; +import develup.domain.hashtag.HashTagRepository; +import org.springframework.stereotype.Service; + +@Service +public class HashTagService { + + private final HashTagRepository hashTagRepository; + + public HashTagService(HashTagRepository hashTagRepository) { + this.hashTagRepository = hashTagRepository; + } + + public List getHashTags() { + return hashTagRepository.findAll().stream() + .map(HashTagResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/develup/application/mission/MissionService.java b/backend/src/main/java/develup/application/mission/MissionService.java index 3e97894c..8edd96a7 100644 --- a/backend/src/main/java/develup/application/mission/MissionService.java +++ b/backend/src/main/java/develup/application/mission/MissionService.java @@ -24,8 +24,8 @@ public MissionService(MissionRepository missionRepository, SolutionRepository so this.solutionRepository = solutionRepository; } - public List getMissions() { - return missionRepository.findAllHashTaggedMission().stream() + public List getMissions(String hashTagName) { + return missionRepository.findAllByHashTagName(hashTagName).stream() .map(MissionResponse::from) .toList(); } diff --git a/backend/src/main/java/develup/application/solution/SolutionService.java b/backend/src/main/java/develup/application/solution/SolutionService.java index 57639fbf..59bb7114 100644 --- a/backend/src/main/java/develup/application/solution/SolutionService.java +++ b/backend/src/main/java/develup/application/solution/SolutionService.java @@ -103,8 +103,8 @@ public SolutionResponse getById(Long id) { return SolutionResponse.from(solution); } - public List getCompletedSummaries() { - return solutionRepository.findAllCompletedSolution().stream() + public List getCompletedSummaries(String hashTagName) { + return solutionRepository.findAllCompletedSolutionByHashTagName(hashTagName).stream() .map(SummarizedSolutionResponse::from) .toList(); } diff --git a/backend/src/main/java/develup/domain/IdentifiableEntity.java b/backend/src/main/java/develup/domain/IdentifiableEntity.java index 05610583..228f2dda 100644 --- a/backend/src/main/java/develup/domain/IdentifiableEntity.java +++ b/backend/src/main/java/develup/domain/IdentifiableEntity.java @@ -35,7 +35,7 @@ public boolean equals(Object o) { return false; } IdentifiableEntity that = (IdentifiableEntity) o; - return Objects.equals(getId(), that.getId()); + return this.getId() != null && Objects.equals(getId(), that.getId()); } @Override diff --git a/backend/src/main/java/develup/domain/hashtag/HashTag.java b/backend/src/main/java/develup/domain/hashtag/HashTag.java index ee3a5ef6..f7bdbb7d 100644 --- a/backend/src/main/java/develup/domain/hashtag/HashTag.java +++ b/backend/src/main/java/develup/domain/hashtag/HashTag.java @@ -1,18 +1,11 @@ package develup.domain.hashtag; -import java.util.Objects; +import develup.domain.IdentifiableEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; @Entity -public class HashTag { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class HashTag extends IdentifiableEntity { @Column(nullable = false) private String name; @@ -25,32 +18,11 @@ public HashTag(String name) { } public HashTag(Long id, String name) { - this.id = id; + super(id); this.name = name; } - public Long getId() { - return id; - } - public String getName() { return name; } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof HashTag hashTag)) { - return false; - } - - return this.getId() != null && Objects.equals(getId(), hashTag.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(getId()); - } } diff --git a/backend/src/main/java/develup/domain/member/Member.java b/backend/src/main/java/develup/domain/member/Member.java index e1b84b9a..6aa26cc7 100644 --- a/backend/src/main/java/develup/domain/member/Member.java +++ b/backend/src/main/java/develup/domain/member/Member.java @@ -1,20 +1,13 @@ package develup.domain.member; -import java.util.Objects; +import develup.domain.CreatedAtAuditableEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; @Entity -public class Member { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class Member extends CreatedAtAuditableEntity { @Column private String email; @@ -40,6 +33,7 @@ public Member(String email, Provider provider, Long socialId, String name, Strin } public Member(Long id, String email, Provider provider, Long socialId, String name, String imageUrl) { + super(id); this.id = id; this.email = email; this.provider = provider; @@ -48,10 +42,6 @@ public Member(Long id, String email, Provider provider, Long socialId, String na this.imageUrl = imageUrl; } - public Long getId() { - return id; - } - public String getEmail() { return email; } @@ -71,21 +61,4 @@ public String getName() { public String getImageUrl() { return imageUrl; } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof Member member)) { - return false; - } - - return this.getId() != null && Objects.equals(getId(), member.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(getId()); - } } diff --git a/backend/src/main/java/develup/domain/mission/Mission.java b/backend/src/main/java/develup/domain/mission/Mission.java index 59bf29b1..161771a5 100644 --- a/backend/src/main/java/develup/domain/mission/Mission.java +++ b/backend/src/main/java/develup/domain/mission/Mission.java @@ -2,24 +2,18 @@ import java.util.List; import java.util.Set; +import develup.domain.IdentifiableEntity; import develup.domain.hashtag.HashTag; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; @Entity -public class Mission { +public class Mission extends IdentifiableEntity { private static final String DESCRIPTION_BASE_URL_PREFIX = "https://raw.githubusercontent.com/develup-mission/"; private static final String DESCRIPTION_BASE_URL_SUFFIX = "/main/README.md"; - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false) private String title; @@ -43,7 +37,7 @@ public Mission(String title, String thumbnail, String summary, String url, List< } public Mission(Long id, String title, String thumbnail, String summary, String url, List hashTags) { - this.id = id; + super(id); this.title = title; this.thumbnail = thumbnail; this.summary = summary; @@ -61,10 +55,6 @@ public String getDescriptionUrl() { return DESCRIPTION_BASE_URL_PREFIX + split[split.length - 1] + DESCRIPTION_BASE_URL_SUFFIX; } - public Long getId() { - return id; - } - public String getTitle() { return title; } diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTag.java b/backend/src/main/java/develup/domain/mission/MissionHashTag.java index 2aa623c9..9964a790 100644 --- a/backend/src/main/java/develup/domain/mission/MissionHashTag.java +++ b/backend/src/main/java/develup/domain/mission/MissionHashTag.java @@ -1,20 +1,14 @@ package develup.domain.mission; +import develup.domain.IdentifiableEntity; import develup.domain.hashtag.HashTag; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @Entity -public class MissionHashTag { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class MissionHashTag extends IdentifiableEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(nullable = false) @@ -32,15 +26,11 @@ public MissionHashTag(Mission mission, HashTag hashTag) { } public MissionHashTag(Long id, Mission mission, HashTag hashTag) { - this.id = id; + super(id); this.mission = mission; this.hashTag = hashTag; } - public Long getId() { - return id; - } - public Mission getMission() { return mission; } diff --git a/backend/src/main/java/develup/domain/mission/MissionRepository.java b/backend/src/main/java/develup/domain/mission/MissionRepository.java index d30d8d15..831c0f97 100644 --- a/backend/src/main/java/develup/domain/mission/MissionRepository.java +++ b/backend/src/main/java/develup/domain/mission/MissionRepository.java @@ -24,6 +24,15 @@ public interface MissionRepository extends JpaRepository { FROM Mission m JOIN FETCH m.missionHashTags.hashTags mhts JOIN FETCH mhts.hashTag ht + WHERE + EXISTS ( + SELECT 1 + FROM MissionHashTag smht + JOIN smht.hashTag sht + WHERE + smht.mission.id = m.id AND + (LOWER(:name) = 'all' OR sht.name = :name) + ) """) - List findAllHashTaggedMission(); + List findAllByHashTagName(String name); } diff --git a/backend/src/main/java/develup/domain/solution/Solution.java b/backend/src/main/java/develup/domain/solution/Solution.java index fe3a6228..7ccb215d 100644 --- a/backend/src/main/java/develup/domain/solution/Solution.java +++ b/backend/src/main/java/develup/domain/solution/Solution.java @@ -3,6 +3,7 @@ import java.util.Set; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; +import develup.domain.CreatedAtAuditableEntity; import develup.domain.member.Member; import develup.domain.mission.Mission; import develup.domain.mission.MissionHashTag; @@ -12,25 +13,18 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @Entity -public class Solution { +public class Solution extends CreatedAtAuditableEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @JoinColumn(nullable = false) @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) private Mission mission; - @JoinColumn(nullable = false) @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) private Member member; @Embedded @@ -69,7 +63,7 @@ public Solution( String url, SolutionStatus status ) { - this.id = id; + super(id); this.mission = mission; this.member = member; this.title = title; @@ -97,10 +91,6 @@ public boolean isInProgress() { return status.isInProgress(); } - public Long getId() { - return id; - } - public Mission getMission() { return mission; } diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepository.java b/backend/src/main/java/develup/domain/solution/SolutionRepository.java index 3cf6e76e..3796b450 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepository.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepository.java @@ -15,10 +15,19 @@ public interface SolutionRepository extends JpaRepository { JOIN FETCH s.mission m JOIN FETCH m.missionHashTags.hashTags mhts JOIN FETCH mhts.hashTag ht - WHERE s.status = 'COMPLETED' + WHERE + s.status = 'COMPLETED' AND + EXISTS ( + SELECT 1 + FROM MissionHashTag smht + JOIN smht.hashTag sht + WHERE + smht.mission.id = m.id AND + (LOWER(:name) = 'all' OR sht.name = :name) + ) ORDER BY s.id DESC """) - List findAllCompletedSolution(); + List findAllCompletedSolutionByHashTagName(String name); List findAllByMember_IdAndStatus(Long memberId, SolutionStatus status); diff --git a/backend/src/main/resources/application-common.yml b/backend/src/main/resources/application-common.yml deleted file mode 100644 index 4eca0c54..00000000 --- a/backend/src/main/resources/application-common.yml +++ /dev/null @@ -1,52 +0,0 @@ -server: - tomcat: - mbeanregistry: # JMX 의 mbean ν™œμ„±ν™” - enabled: true - -spring: - h2: - console: - enabled: true - path: /h2-console - datasource: - url: jdbc:h2:mem:database; - driver-class-name: org.h2.Driver - jpa: - show-sql: true - defer-datasource-initialization: true - properties: - hibernate: - format_sql: true - highlight_sql: true - hibernate: - ddl-auto: create - -springdoc: - default-consumes-media-type: application/json - default-produces-media-type: application/json - swagger-ui: - path: /api-docs - disable-swagger-default-url: true - display-request-duration: true - operations-sorter: alpha - tags-sorter: alpha - -logging: - level: - org.springframework.orm.jpa: DEBUG - org.springframework.orm.transaction: DEBUG - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - -security: - jwt: - secret-key: 7b3dc341c033816045cc11c6f9fba3187f4dbdb7 - expiration-time: 3600000 # 1 hour - -management: - server: - port: 8082 - endpoints: - web: - exposure: - include: "prometheus,health" diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml deleted file mode 100644 index 91a325f3..00000000 --- a/backend/src/main/resources/application-dev.yml +++ /dev/null @@ -1,9 +0,0 @@ -api-host: https://api.devel-up.co.kr -client-host: https://www.devel-up.co.kr - -auth: - github: - client-id: develup-test-develup-test-develup-test-develup-test-develup-test - client-secret: develup-test-develup-test-develup-test-develup-test-develup-test - redirect-uri: ${api-host}/auth/social/callback/github - client-uri: ${client-host} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml deleted file mode 100644 index 1a6f849d..00000000 --- a/backend/src/main/resources/application-local.yml +++ /dev/null @@ -1,9 +0,0 @@ -api-host: http://localhost:8080 -client-host: http://localhost:3000 - -auth: - github: - client-id: develup-test-develup-test-develup-test-develup-test-develup-test - client-secret: develup-test-develup-test-develup-test-develup-test-develup-test - redirect-uri: ${api-host}/auth/social/callback/github - client-uri: ${client-host} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml deleted file mode 100644 index 1e5938df..00000000 --- a/backend/src/main/resources/application.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - profiles: - active: local - group: - local: local, common - dev: dev, common diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 17636648..be8486af 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -1,48 +1,53 @@ -INSERT INTO member (email, provider, social_id, name, image_url) -VALUES ('test1@gmail.com', 'GITHUB', '1234', 'ꡬ름', 'www.naver.com'); - -INSERT INTO member (email, provider, social_id, name, image_url) -VALUES ('test1@gmail.com', 'GITHUB', '1234', '리브', 'www.naver.com'); - -INSERT INTO member (email, provider, social_id, name, image_url) -VALUES ('test1@gmail.com', 'GITHUB', '1234', 'μ•„ν†°', 'www.naver.com'); +INSERT INTO member (email, provider, social_id, name, image_url, created_at) +VALUES ('test1@gmail.com', 'GITHUB', '1234', 'ꡬ름', 'https://avatars.githubusercontent.com/u/75781414?v=4', + '2024-08-16 13:40:00'), + ('test1@gmail.com', 'GITHUB', '1234', '리브', 'https://avatars.githubusercontent.com/u/131349867?v=4', + '2024-08-16 13:40:00'), + ('test1@gmail.com', 'GITHUB', '1234', 'μ•„ν†°', 'https://avatars.githubusercontent.com/u/39932141?v=4', + '2024-08-16 13:40:00'); INSERT INTO mission (title, thumbnail, summary, url) VALUES ('λ£¨ν„°νšŒκ΄€ 흑연 단속', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-smoking.png', - 'λ‹΄λ°°ν”Όλ‹€ κ±Έλ¦° 행성이λ₯Ό μœ„ν•œ 벌금 계산 λ―Έμ…˜', 'https://github.com/develup-mission/java-smoking'); -INSERT INTO mission (title, thumbnail, summary, url) -VALUES ('숫자 λ§žμΆ”κΈ° κ²Œμž„', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', + 'λ‹΄λ°°ν”Όλ‹€ κ±Έλ¦° 행성이λ₯Ό μœ„ν•œ 벌금 계산 λ―Έμ…˜', 'https://github.com/develup-mission/java-smoking'), + ('숫자 λ§žμΆ”κΈ° κ²Œμž„', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', '숫자λ₯Ό 맞좰보자', 'https://github.com/develup-mission/java-guessing-number'); -INSERT INTO hash_tag (name) VALUES ('JAVA'); -INSERT INTO hash_tag (name) VALUES ('객체지ν–₯'); -INSERT INTO hash_tag (name) VALUES ('TDD'); -INSERT INTO hash_tag (name) VALUES ('ν΄λ¦°μ½”λ“œ'); -INSERT INTO hash_tag (name) VALUES ('레벨1'); +INSERT INTO hash_tag (name) +VALUES ('JAVA'), + ('객체지ν–₯'), + ('TDD'), + ('ν΄λ¦°μ½”λ“œ'), + ('레벨1'), + ('μž μ‹€μΊ νΌμŠ€'), + ('μ„ λ¦‰μΊ νΌμŠ€'); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 1); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 2); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 3); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 4); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 5); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 1); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 2); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 3); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 4); -INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 5); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) +VALUES (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (1, 6), + (2, 1), + (2, 2), + (2, 3), + (2, 4), + (2, 5), + (2, 7); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (1, 1, '릴리 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (1, 2, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (1, 3, '라이언 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (2, 1, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (2, 2, '릴리 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) -VALUES (2, 3, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status, created_at) +VALUES (1, 1, '릴리 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'), + (1, 2, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'), + (1, 3, '라이언 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'), + (2, 1, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'), + (2, 2, '릴리 λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'), + (2, 3, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œλ¦½λ‹ˆλ‹€.', 'https://github.com/develup/mission/pull/1', 'COMPLETED', + '2024-08-16 13:40:00'); -- root-1 -- γ„΄ root-1-1 @@ -52,16 +57,10 @@ VALUES (2, 3, 'μ•„ν†° λ―Έμ…˜ μ œμΆœν•©λ‹ˆλ‹€.', 'μ•ˆλ…•ν•˜μ„Έμš”. 잘 뢀탁 λ“œ -- γ„΄ root-2-2 (deleted, view x) -- root-3 (deleted, view x) INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '1', NULL, NULL, '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '2', NULL, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '3', NULL, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '2-1', 2, NULL, '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '1-1', 1, NULL, '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '2-2', 2, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); -INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) -VALUES (1, 1, '1-2', 1, NULL, '2021-08-01 00:00:00'); +VALUES (1, 1, '1', NULL, NULL, '2024-08-16 13:40:00'), + (1, 1, '2', NULL, '2024-08-12 13:40:00', '2024-08-16 13:40:00'), + (1, 1, '3', NULL, '2024-08-12 13:40:00', '2024-08-16 13:40:00'), + (1, 1, '2-1', 2, NULL, '2024-08-16 13:40:00'), + (1, 1, '1-1', 1, NULL, '2024-08-16 13:40:00'), + (1, 1, '2-2', 2, '2024-08-12 13:40:00', '2024-08-16 13:40:00'), + (1, 1, '1-2', 1, NULL, '2024-08-16 13:40:00'); diff --git a/backend/src/test/java/develup/api/ApiTestSupport.java b/backend/src/test/java/develup/api/ApiTestSupport.java index 86441786..b09e9a9d 100644 --- a/backend/src/test/java/develup/api/ApiTestSupport.java +++ b/backend/src/test/java/develup/api/ApiTestSupport.java @@ -4,6 +4,7 @@ import develup.api.auth.AuthArgumentResolver; import develup.api.auth.CookieAuthorizationExtractor; import develup.application.auth.AuthService; +import develup.application.hashtag.HashTagService; import develup.application.member.MemberService; import develup.application.mission.MissionService; import develup.application.solution.SolutionService; @@ -38,8 +39,11 @@ public class ApiTestSupport { protected SolutionCommentService solutionCommentService; @MockBean - protected CookieAuthorizationExtractor cookieAuthorizationExtractor; + protected HashTagService hashTagService; @MockBean protected AuthArgumentResolver argumentResolver; + + @MockBean + protected CookieAuthorizationExtractor cookieAuthorizationExtractor; } diff --git a/backend/src/test/java/develup/api/HashTagApiTest.java b/backend/src/test/java/develup/api/HashTagApiTest.java new file mode 100644 index 00000000..2d377b13 --- /dev/null +++ b/backend/src/test/java/develup/api/HashTagApiTest.java @@ -0,0 +1,37 @@ +package develup.api; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import develup.application.hashtag.HashTagResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; + +class HashTagApiTest extends ApiTestSupport { + + @Test + @DisplayName("ν•΄μ‹œ νƒœκ·Έ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€.") + void getHashTags() throws Exception { + List responses = List.of( + new HashTagResponse(1L, "JAVA"), + new HashTagResponse(2L, "JAVASCRIPT") + ); + BDDMockito.given(hashTagService.getHashTags()) + .willReturn(responses); + + mockMvc.perform(get("/hash-tags")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id", is(1))) + .andExpect(jsonPath("$.data[0].name", equalTo("JAVA"))) + .andExpect(jsonPath("$.data[1].id", is(2))) + .andExpect(jsonPath("$.data[1].name", equalTo("JAVASCRIPT"))) + .andExpect(jsonPath("$.data.length()", is(2))); + } +} diff --git a/backend/src/test/java/develup/api/MissionApiTest.java b/backend/src/test/java/develup/api/MissionApiTest.java index 28b91ece..a28e3fb2 100644 --- a/backend/src/test/java/develup/api/MissionApiTest.java +++ b/backend/src/test/java/develup/api/MissionApiTest.java @@ -29,7 +29,7 @@ void getMissions() throws Exception { MissionResponse.from(mission), MissionResponse.from(mission) ); - BDDMockito.given(missionService.getMissions()) + BDDMockito.given(missionService.getMissions(any())) .willReturn(responses); mockMvc.perform(get("/missions")) diff --git a/backend/src/test/java/develup/api/SolutionApiTest.java b/backend/src/test/java/develup/api/SolutionApiTest.java index 5bb1eef2..a560b2b2 100644 --- a/backend/src/test/java/develup/api/SolutionApiTest.java +++ b/backend/src/test/java/develup/api/SolutionApiTest.java @@ -37,7 +37,7 @@ void getSolutions() throws Exception { SummarizedSolutionResponse.from(createSolution()), SummarizedSolutionResponse.from(createSolution()) ); - BDDMockito.given(solutionService.getCompletedSummaries()) + BDDMockito.given(solutionService.getCompletedSummaries(any())) .willReturn(responses); mockMvc.perform(get("/solutions")) diff --git a/backend/src/test/java/develup/application/mission/MissionServiceTest.java b/backend/src/test/java/develup/application/mission/MissionServiceTest.java index 43a14eb1..23092868 100644 --- a/backend/src/test/java/develup/application/mission/MissionServiceTest.java +++ b/backend/src/test/java/develup/application/mission/MissionServiceTest.java @@ -47,7 +47,7 @@ void getMissions() { createMission(); createMission(); - List responses = missionService.getMissions(); + List responses = missionService.getMissions("all"); assertThat(responses).hasSize(2); } diff --git a/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java index e5201a7e..49c97c14 100644 --- a/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java +++ b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; +import java.util.function.Function; import develup.domain.hashtag.HashTag; import develup.domain.hashtag.HashTagRepository; import develup.support.IntegrationTestSupport; @@ -46,7 +47,7 @@ void findHashTaggedMissionById() { @Test @DisplayName("ν•΄μ‹œνƒœκ·Έκ°€ μ‘΄μž¬ν•˜λŠ” λͺ¨λ“  λ―Έμ…˜μ„ μ‘°νšŒν•œλ‹€.") - void findAllHashTaggedMission() { + void all() { HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); Mission mission1 = MissionTestData.defaultMission() .withHashTags(List.of(hashTag)) @@ -56,8 +57,25 @@ void findAllHashTaggedMission() { .build(); missionRepository.saveAll(List.of(mission1, mission2)); - List missions = missionRepository.findAllHashTaggedMission(); + List missions = missionRepository.findAllByHashTagName("all"); assertThat(missions).hasSize(2); } + + @Test + @DisplayName("νŠΉμ • ν•΄μ‹œνƒœκ·Έκ°€ νƒœκ·Έλœ λͺ¨λ“  λ―Έμ…˜μ„ μ‘°νšŒν•œλ‹€.") + void findAllByHashTagName() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); + Mission mission1 = MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build(); + Mission mission2 = MissionTestData.defaultMission().build(); + missionRepository.saveAll(List.of(mission1, mission2)); + + List missions = missionRepository.findAllByHashTagName("JAVA"); + + assertThat(missions) + .map(Mission::getHashTags) + .flatMap(Function.identity()) + .map(MissionHashTag::getHashTag) + .contains(hashTag); + } } diff --git a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java index 60044a41..8cc3130b 100644 --- a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java +++ b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java @@ -5,11 +5,13 @@ import java.util.List; import java.util.Optional; +import java.util.function.Function; import develup.domain.hashtag.HashTag; import develup.domain.hashtag.HashTagRepository; import develup.domain.member.Member; import develup.domain.member.MemberRepository; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; import develup.domain.mission.MissionRepository; import develup.support.IntegrationTestSupport; import develup.support.data.HashTagTestData; @@ -73,11 +75,40 @@ void findAllCompletedSolution() { createSolution(SolutionStatus.COMPLETED); createSolution(SolutionStatus.IN_PROGRESS); - List actual = solutionRepository.findAllCompletedSolution(); + List actual = solutionRepository.findAllCompletedSolutionByHashTagName("all"); assertThat(actual).hasSize(2); } + @Test + @DisplayName("주어진 ν•΄μ‹œνƒœκ·Έκ°€ ν¬ν•¨λœ μ™„λ£Œλœ μ†”λ£¨μ…˜μ„ μ‘°νšŒν•  수 μžˆλ‹€.") + void findAllCompletedSolutionByHashTag() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); + Mission mission1 = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); + Mission mission2 = missionRepository.save(MissionTestData.defaultMission().build()); + Solution solution1 = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission1) + .withStatus(SolutionStatus.COMPLETED) + .build(); + Solution solution2 = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission2) + .withStatus(SolutionStatus.COMPLETED) + .build(); + + solutionRepository.saveAll(List.of(solution1, solution2)); + + List solutions = solutionRepository.findAllCompletedSolutionByHashTagName("JAVA"); + + assertThat(solutions) + .map(Solution::getHashTags) + .flatMap(Function.identity()) + .map(MissionHashTag::getHashTag) + .contains(hashTag); + } + @Test @DisplayName("멀버 μ‹λ³„μžμ™€ λ―Έμ…˜ μ‹λ³„μžμ™€ νŠΉμ • μƒνƒœμ— ν•΄λ‹Ήν•˜λŠ” μ†”λ£¨μ…˜μ„ μ‘°νšŒν•œλ‹€.") void findByMember_IdAndMission_IdAndStatus() { diff --git a/frontend/src/apis/solutions.ts b/frontend/src/apis/solutions.ts index c482173a..fc9b0553 100644 --- a/frontend/src/apis/solutions.ts +++ b/frontend/src/apis/solutions.ts @@ -1,7 +1,7 @@ import { develupAPIClient } from '@/apis/clients/develupClient'; import { PATH } from '@/apis/paths'; import SubmittedSolutions from '@/mocks/SubmittedSolutions.json'; -import { HashTag } from '@/types/mission'; +import type { HashTag } from '@/types/mission'; import type { Solution, SubmittedSolution } from '@/types/solution'; export interface SolutionSummary { diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 08224881..5edf5158 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -2,6 +2,7 @@ export const ERROR_MESSAGE = { not_defined_context: 'μ»¨ν…μŠ€νŠΈκ°€ μ •μ˜ λ˜μ§€ μ•Šμ•˜μ–΄μš”!', invalid_pr: 'μœ νš¨ν•˜μ§€ μ•Šμ€ PR ν˜•μ‹μ΄μ—μš”!', invalid_title: 'μœ νš¨ν•˜μ§€ μ•Šμ€ 제λͺ©μ΄μ—μš”!', + duplicate_request: 'μš”μ²­μ΄ μ€‘λ³΅λ˜μ—ˆμ–΄μš”!', } as const; export const PROGRESS_MESSAGE = { diff --git a/frontend/src/hooks/__tests__/useSingleRequestMutation.test.tsx b/frontend/src/hooks/__tests__/useSingleRequestMutation.test.tsx new file mode 100644 index 00000000..c2d2bfe3 --- /dev/null +++ b/frontend/src/hooks/__tests__/useSingleRequestMutation.test.tsx @@ -0,0 +1,128 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import useSingleRequestMutation from '../useSingleRequestMutation'; +import type { PropsWithChildren } from 'react'; + +const createQueryClient = () => new QueryClient(); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +const mockVariable = jest.fn(); +const mockMutationFn = jest.fn().mockResolvedValue('success'); +const mockOnMutate = jest.fn(); +const mockOnSuccess = jest.fn(); +const mockOnError = jest.fn(); +const mockOnSettled = jest.fn(); + +describe('useSingleRequestMutation ν›… ν…ŒμŠ€νŠΈ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('초기 μƒνƒœκ°€ μ •μƒμ μœΌλ‘œ μ„€μ •λœλ‹€.', () => { + const { result } = renderHook(() => useSingleRequestMutation({ queryFn: mockMutationFn }), { + wrapper, + }); + + expect(result.current.mutate).toBeDefined(); + }); + + it('μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μˆ˜ν–‰λ˜λ©΄ onSuccess와 onSettledκ°€ ν˜ΈμΆœλœλ‹€.', async () => { + const { result } = renderHook( + () => + useSingleRequestMutation({ + queryFn: mockMutationFn, + onSuccess: mockOnSuccess, + onSettled: mockOnSettled, + }), + { wrapper }, + ); + + result.current.mutate(undefined); + + await waitFor(() => { + expect(mockMutationFn).toHaveBeenCalledTimes(1); + expect(mockOnSuccess).toHaveBeenCalledWith('success', undefined, undefined); + expect(mockOnSettled).toHaveBeenCalledWith('success', null, undefined, undefined); + }); + }); + + it('μš”μ²­μ΄ μ‹€νŒ¨ν•˜λ©΄ onError와 onSettledκ°€ ν˜ΈμΆœλœλ‹€.', async () => { + const error = new Error('error'); + mockMutationFn.mockRejectedValueOnce(error); + const { result } = renderHook( + () => + useSingleRequestMutation({ + queryFn: mockMutationFn, + onError: mockOnError, + onSettled: mockOnSettled, + }), + { wrapper }, + ); + + result.current.mutate(undefined); + + await waitFor(() => { + expect(mockMutationFn).toHaveBeenCalledTimes(1); + expect(mockOnError).toHaveBeenCalledWith(error, undefined, undefined); + expect(mockOnSettled).toHaveBeenCalledWith(undefined, error, undefined, undefined); + }); + }); + + it('λ™μΌν•œ requestId둜 쀑볡 μš”μ²­μ΄ λ°©μ§€λœλ‹€.', async () => { + const { result } = renderHook( + () => useSingleRequestMutation({ queryFn: mockMutationFn, requestId: 'testRequestId' }), + { wrapper }, + ); + + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + result.current.mutate(mockVariable); + + await waitFor(() => { + expect(mockMutationFn).toHaveBeenCalledTimes(1); + }); + }); + + it('λ‹€λ₯Έ requestId둜 μš”μ²­μ΄ κ°€λŠ₯ν•˜λ‹€.', async () => { + const { result: result1 } = renderHook( + () => useSingleRequestMutation({ queryFn: mockMutationFn, requestId: 'requestId1' }), + { wrapper }, + ); + + const { result: result2 } = renderHook( + () => useSingleRequestMutation({ queryFn: mockMutationFn, requestId: 'requestId2' }), + { wrapper }, + ); + + result1.current.mutate(mockVariable); + result2.current.mutate(mockVariable); + + await waitFor(() => { + expect(mockMutationFn).toHaveBeenCalledTimes(2); + }); + }); + + it('onMutateκ°€ 호좜되면 μš”μ²­ 전에 μ‹€ν–‰λœλ‹€.', async () => { + const { result } = renderHook( + () => useSingleRequestMutation({ queryFn: mockMutationFn, onMutate: mockOnMutate }), + { wrapper }, + ); + + result.current.mutate(mockVariable); + + await waitFor(() => { + expect(mockOnMutate).toHaveBeenCalled(); + expect(mockMutationFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/hooks/queries/keys.ts b/frontend/src/hooks/queries/keys.ts index 0df71b08..4c49f065 100644 --- a/frontend/src/hooks/queries/keys.ts +++ b/frontend/src/hooks/queries/keys.ts @@ -12,5 +12,10 @@ export const missionKeys = { export const solutionKeys = { all: ['solutions'], + summaries: ['solutionSummaries'], submitted: ['submitted solutions'], }; + +export const userKeys = { + info: ['userInfo'], +}; diff --git a/frontend/src/hooks/useSingleRequest.ts b/frontend/src/hooks/useSingleRequest.ts new file mode 100644 index 00000000..e1f57869 --- /dev/null +++ b/frontend/src/hooks/useSingleRequest.ts @@ -0,0 +1,23 @@ +import { useRef } from 'react'; +import { ERROR_MESSAGE } from '@/constants/messages'; + +const useSingleRequest = () => { + const apiRequests = useRef>(new Set()); + + const startRequest = (requestId: string): boolean => { + if (apiRequests.current.has(requestId)) { + console.warn(ERROR_MESSAGE.duplicate_request); + return false; + } + apiRequests.current.add(requestId); + return true; + }; + + const endRequest = (requestId: string): void => { + apiRequests.current.delete(requestId); + }; + + return { startRequest, endRequest }; +}; + +export default useSingleRequest; diff --git a/frontend/src/hooks/useSingleRequestMutation.ts b/frontend/src/hooks/useSingleRequestMutation.ts new file mode 100644 index 00000000..f2e75ddf --- /dev/null +++ b/frontend/src/hooks/useSingleRequestMutation.ts @@ -0,0 +1,56 @@ +import { useMutation } from '@tanstack/react-query'; +import type { UseMutationResult, UseMutationOptions } from '@tanstack/react-query'; +import useSingleRequest from './useSingleRequest'; +import { ERROR_MESSAGE } from '@/constants/messages'; + +interface SingleFlightMutationOptions + extends UseMutationOptions { + requestId?: string; + queryFn: (variables: TVariables) => Promise; +} + +const useSingleRequestMutation = ( + options: SingleFlightMutationOptions, +): UseMutationResult => { + const { startRequest, endRequest } = useSingleRequest(); + const requestId = options?.requestId || 'defaultRequestId'; + + return useMutation({ + ...options, + mutationFn: options.queryFn, + onMutate: async (variables: TVariables) => { + const canProceed = startRequest(requestId); + if (!canProceed) { + throw new Error(ERROR_MESSAGE.duplicate_request); + } + if (options?.onMutate) { + return await options.onMutate(variables); + } + }, + onSuccess: (data: TData, variables: TVariables, context: unknown) => { + endRequest(requestId); + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + onError: (error: TError, variables: TVariables, context: unknown) => { + endRequest(requestId); + if (options?.onError) { + options.onError(error, variables, context); + } + }, + onSettled: ( + data: TData | undefined, + error: TError | null, + variables: TVariables, + context: unknown, + ) => { + endRequest(requestId); + if (options?.onSettled) { + options.onSettled(data, error, variables, context); + } + }, + }); +}; + +export default useSingleRequestMutation; diff --git a/frontend/src/hooks/useSolutions.ts b/frontend/src/hooks/useSolutions.ts index 33f41441..fe871973 100644 --- a/frontend/src/hooks/useSolutions.ts +++ b/frontend/src/hooks/useSolutions.ts @@ -4,7 +4,7 @@ import { solutionKeys } from './queries/keys'; const useSolutionSummaries = () => { return useSuspenseQuery({ - queryKey: solutionKeys.all, + queryKey: solutionKeys.summaries, queryFn: getSolutionSummaries, }); }; diff --git a/frontend/src/hooks/useSubmitSolutionMutation.ts b/frontend/src/hooks/useSubmitSolutionMutation.ts index d86504fd..6f497ffb 100644 --- a/frontend/src/hooks/useSubmitSolutionMutation.ts +++ b/frontend/src/hooks/useSubmitSolutionMutation.ts @@ -1,27 +1,30 @@ -import { useMutation } from '@tanstack/react-query'; import { postSolutionSubmit } from '@/apis/solutions'; import { queryClient } from '@/index'; -import { missionKeys } from './queries/keys'; +import { missionKeys, solutionKeys } from './queries/keys'; +import useSingleRequestMutation from './useSingleRequestMutation'; interface UseSubmissionMutationParams { onSuccessCallback: () => void; missionId: number; } +const SINGLE_REQUEST_ID = 'submit_solution'; + const useSubmitSolutionMutation = ({ onSuccessCallback, missionId, }: UseSubmissionMutationParams) => { - const { mutate: submitSolutionMutation, isPending } = useMutation({ - mutationFn: postSolutionSubmit, + const { mutate: submitSolutionMutation, isPending } = useSingleRequestMutation({ + queryFn: postSolutionSubmit, onSuccess: () => { onSuccessCallback(); queryClient.invalidateQueries({ queryKey: missionKeys.detail(missionId) }); - queryClient.invalidateQueries({ queryKey: ['solutionSummaries'] }); + queryClient.invalidateQueries({ queryKey: solutionKeys.summaries }); }, onError: (error: Error) => { console.error(error.message); }, + requestId: SINGLE_REQUEST_ID, }); return { submitSolutionMutation, isPending }; diff --git a/frontend/src/hooks/useUserInfo.ts b/frontend/src/hooks/useUserInfo.ts index 8c040904..b143bff5 100644 --- a/frontend/src/hooks/useUserInfo.ts +++ b/frontend/src/hooks/useUserInfo.ts @@ -1,10 +1,11 @@ import { getUserInfo } from '@/apis/authAPI'; import { useQuery } from '@tanstack/react-query'; import type { UserInfo } from '../types/user'; +import { userKeys } from './queries/keys'; const useUserInfo = () => { return useQuery({ - queryKey: ['userInfo'], + queryKey: userKeys.info, queryFn: getUserInfo, retry: false, throwOnError: false,