From 292411048619bbe6c4c87b4025dc0fb82be579b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EC=88=98=EB=B9=88?= Date: Wed, 14 Aug 2024 17:47:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?test:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A1=9C=20Api=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=ED=95=98=EB=8A=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/develup/application/auth/OAuthUserInfo.java | 6 ++++++ .../main/java/develup/infra/auth/github/GithubUserInfo.java | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/develup/application/auth/OAuthUserInfo.java b/backend/src/main/java/develup/application/auth/OAuthUserInfo.java index 210168dc7..5b79ec842 100644 --- a/backend/src/main/java/develup/application/auth/OAuthUserInfo.java +++ b/backend/src/main/java/develup/application/auth/OAuthUserInfo.java @@ -12,6 +12,12 @@ public record OAuthUserInfo( String name ) { + public OAuthUserInfo { + if (name == null || name.isBlank()) { + name = login; + } + } + public Member toMember(Provider provider) { return new Member( email, diff --git a/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java b/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java index 81bc17f8a..302da52fc 100644 --- a/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java +++ b/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java @@ -11,7 +11,7 @@ public record GithubUserInfo( String login, String avatarUrl, @Nullable String email, - String name + @Nullable String name ) { public OAuthUserInfo toOAuthUserInfo() { From 4d8b45caceabc5e30a20f660de217a90bdfb7ce6 Mon Sep 17 00:00:00 2001 From: yoonseo choi Date: Wed, 14 Aug 2024 23:05:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EB=82=B4=EA=B0=80=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=ED=95=9C=20=EC=86=94=EB=A3=A8=EC=85=98=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(issue=20#288)=20(#293)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 내가 제출한 솔루션 리스트 조회 기능 구현 * refactor: Solution thumbnail getter 이름 수정 * refactor: 멤버식별자와 상태로 솔루션을 조회하는 메서드의 네이밍 변경 * refactor: SolutionServiceTest 의 불필요한 Accessor 제거 --- .../main/java/develup/api/SolutionApi.java | 9 ++++ .../solution/MySolutionResponse.java | 10 ++++ .../application/solution/SolutionService.java | 7 +++ .../develup/domain/solution/Solution.java | 4 ++ .../domain/solution/SolutionRepository.java | 2 + .../java/develup/api/SolutionApiTest.java | 22 ++++++++ .../solution/SolutionServiceTest.java | 52 +++++++++++++++---- .../solution/SolutionRepositoryTest.java | 29 +++++++++++ 8 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/develup/application/solution/MySolutionResponse.java diff --git a/backend/src/main/java/develup/api/SolutionApi.java b/backend/src/main/java/develup/api/SolutionApi.java index 8f4897167..44435c602 100644 --- a/backend/src/main/java/develup/api/SolutionApi.java +++ b/backend/src/main/java/develup/api/SolutionApi.java @@ -4,6 +4,7 @@ import develup.api.auth.Auth; import develup.api.common.ApiResponse; import develup.application.auth.Accessor; +import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionResponse; import develup.application.solution.SolutionService; import develup.application.solution.StartSolutionRequest; @@ -66,4 +67,12 @@ public ResponseEntity> getSolution(@PathVariable L return ResponseEntity.ok(new ApiResponse<>(solutionResponse)); } + + @GetMapping("/solutions/mine") + @Operation(summary = "나의 솔루션 목록 조회 API", description = "내가 제출한 솔루션 목록을 조회합니다.") + public ResponseEntity>> getMySolutions(@Auth Accessor accessor) { + List response = solutionService.getSubmittedSolutionsByMemberId(accessor.id()); + + return ResponseEntity.ok(new ApiResponse<>(response)); + } } diff --git a/backend/src/main/java/develup/application/solution/MySolutionResponse.java b/backend/src/main/java/develup/application/solution/MySolutionResponse.java new file mode 100644 index 000000000..2851be5e4 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/MySolutionResponse.java @@ -0,0 +1,10 @@ +package develup.application.solution; + +import develup.domain.solution.Solution; + +public record MySolutionResponse(Long id, String thumbnail, String title) { + + public static MySolutionResponse from(Solution solution) { + return new MySolutionResponse(solution.getId(), solution.getMissionThumbnail(), solution.getTitle()); + } +} diff --git a/backend/src/main/java/develup/application/solution/SolutionService.java b/backend/src/main/java/develup/application/solution/SolutionService.java index 701f4d40e..52717a902 100644 --- a/backend/src/main/java/develup/application/solution/SolutionService.java +++ b/backend/src/main/java/develup/application/solution/SolutionService.java @@ -107,4 +107,11 @@ public SolutionResponse getById(Long id) { public List getCompletedSummaries() { return solutionRepository.findCompletedSummaries(); } + + public List getSubmittedSolutionsByMemberId(Long memberId) { + List mySolutions = solutionRepository.findAllByMember_IdAndStatus(memberId, SolutionStatus.COMPLETED); + return mySolutions.stream() + .map(MySolutionResponse::from) + .toList(); + } } diff --git a/backend/src/main/java/develup/domain/solution/Solution.java b/backend/src/main/java/develup/domain/solution/Solution.java index 2fe05d776..8ce716463 100644 --- a/backend/src/main/java/develup/domain/solution/Solution.java +++ b/backend/src/main/java/develup/domain/solution/Solution.java @@ -103,6 +103,10 @@ public Mission getMission() { return mission; } + public String getMissionThumbnail() { + return mission.getThumbnail(); + } + public Member getMember() { return member; } diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepository.java b/backend/src/main/java/develup/domain/solution/SolutionRepository.java index cd3214e3d..998368965 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepository.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepository.java @@ -18,4 +18,6 @@ public interface SolutionRepository extends JpaRepository { List findCompletedSummaries(); Optional findByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); + + List findAllByMember_IdAndStatus(Long memberId, SolutionStatus solutionStatus); } diff --git a/backend/src/test/java/develup/api/SolutionApiTest.java b/backend/src/test/java/develup/api/SolutionApiTest.java index 840078ba3..3226d4bfa 100644 --- a/backend/src/test/java/develup/api/SolutionApiTest.java +++ b/backend/src/test/java/develup/api/SolutionApiTest.java @@ -11,6 +11,7 @@ import java.util.List; import com.fasterxml.jackson.databind.ObjectMapper; +import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionResponse; import develup.application.solution.StartSolutionRequest; import develup.application.solution.SubmitSolutionRequest; @@ -151,4 +152,25 @@ void startSolution() throws Exception { .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } + + @Test + @DisplayName("나의 솔루션 목록을 조회한다.") + void getMySolutions() throws Exception { + List mySolutions = List.of( + new MySolutionResponse(1L, "thumbnail", "title"), + new MySolutionResponse(2L, "thumbnail", "title") + ); + BDDMockito.given(solutionService.getSubmittedSolutionsByMemberId(any())) + .willReturn(mySolutions); + + mockMvc.perform(get("/solutions/mine")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("thumbnail"))) + .andExpect(jsonPath("$.data[0].title", equalTo("title"))) + .andExpect(jsonPath("$.data[1].id", equalTo(2))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("thumbnail"))) + .andExpect(jsonPath("$.data[1].title", equalTo("title"))); + } } diff --git a/backend/src/test/java/develup/application/solution/SolutionServiceTest.java b/backend/src/test/java/develup/application/solution/SolutionServiceTest.java index 97ba97107..386167bb8 100644 --- a/backend/src/test/java/develup/application/solution/SolutionServiceTest.java +++ b/backend/src/test/java/develup/application/solution/SolutionServiceTest.java @@ -7,7 +7,6 @@ import java.util.Optional; import develup.api.exception.DevelupException; -import develup.application.auth.Accessor; import develup.domain.member.Member; import develup.domain.member.MemberRepository; import develup.domain.mission.Mission; @@ -118,10 +117,9 @@ void create() { .build(); solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = getSolutionRequest(); - SolutionResponse solutionResponse = solutionService.submit(accessor.id(), submitSolutionRequest); + SolutionResponse solutionResponse = solutionService.submit(member.getId(), submitSolutionRequest); assertAll( () -> assertEquals(solutionResponse.id(), 1L), @@ -137,7 +135,6 @@ void create() { @DisplayName("미션 제출 시 value 이 비어있으면 예외가 발생한다.") void createFailWhenTitleIsBlank() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( 1L, "", @@ -145,7 +142,7 @@ void createFailWhenTitleIsBlank() { "https://github.com/develup-mission/java-smoking/pull/1" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(RuntimeException.class); } @@ -162,7 +159,6 @@ void createFailWhenWrongPRUrl() { solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( mission.getId(), "value", @@ -170,7 +166,7 @@ void createFailWhenWrongPRUrl() { "url" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(DevelupException.class) .hasMessage("올바르지 않은 주소입니다."); } @@ -188,7 +184,6 @@ void createFailWhenWrongPRUrlRepository() { solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( mission.getId(), "value", @@ -196,11 +191,50 @@ void createFailWhenWrongPRUrlRepository() { "https://github.com/develup-mission/java-undefinedMission/pull/1" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(DevelupException.class) .hasMessage("올바르지 않은 주소입니다."); } + @Test + @DisplayName("나의 솔루션 리스트를 조회한다.") + void getSubmittedSolutionsByMemberId() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.COMPLETED) + .build(); + + solutionRepository.save(solution); + + assertThat(solutionService.getSubmittedSolutionsByMemberId(member.getId())).hasSize(1); + } + + @Test + @DisplayName("나의 솔루션 리스트 조회 시, 제출 완료 상태가 아닌 솔루션은 조회되지 않는다.") + void shouldNotRetrieveSolutionsThatAreNotCompleted() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution inProgress = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.IN_PROGRESS) + .build(); + + Solution completed = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.COMPLETED) + .build(); + + solutionRepository.save(inProgress); + solutionRepository.save(completed); + + assertThat(solutionService.getSubmittedSolutionsByMemberId(member.getId())).hasSize(1); + } + private SubmitSolutionRequest getSolutionRequest() { return new SubmitSolutionRequest( 1L, diff --git a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java index 4057d56c3..a41953a6f 100644 --- a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java +++ b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java @@ -114,6 +114,35 @@ void findByMember_IdAndMission_IdAndStatus() { ); } + @Test + @DisplayName("멤버 식별자와 특정 상태에 해당하는 솔루션을 조회한다.") + void findByMember_IdAndStatus() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + SolutionStatus inProgress = SolutionStatus.IN_PROGRESS; + SolutionStatus completed = SolutionStatus.COMPLETED; + Solution inProgressSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(inProgress) + .build(); + Solution completeSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(completed) + .build(); + solutionRepository.save(inProgressSolution); + solutionRepository.save(completeSolution); + + List solutionInProgress = solutionRepository.findAllByMember_IdAndStatus(member.getId(), inProgress); + List solutionCompleted = solutionRepository.findAllByMember_IdAndStatus(member.getId(), completed); + + assertAll( + () -> assertThat(solutionInProgress).hasSize(1), + () -> assertThat(solutionCompleted).hasSize(1) + ); + } + private void createSolution(SolutionStatus status) { Member member = memberRepository.save(MemberTestData.defaultMember().build()); Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); From 6bc54d7a8748810a65ba774e00ddd4bf2dc03ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EC=88=98=EB=B9=88?= Date: Wed, 14 Aug 2024 23:24:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=A7=84=ED=96=89=EC=A4=91=EC=9D=B8=20?= =?UTF-8?q?=EB=AF=B8=EC=85=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Issue=20#279)=20(#291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 아이디와 상태로 솔루션을 조회하는 메서드 추가 * feat: 특정 회원이 시작한 미션을 조회하는 메서드 추가 * feat: 특정 회원이 시작한 미션을 조회하는 API 추가 * fix: 그레이들 테스트 실패 시에도 성공 알림을 주는 오류 수정 * fix: BE_CI 실패 시에 BE_SLACK_MESSAGE이 동작하지 않는 오류 수정 * chore: 테스트용 커밋 롤백 * fix: 서비스에 @Transactional 추가 * fix: MissionApiTest에서 미션목록 응답값 변경 * fix: 사용자가 시작한 미션 목록 조회 API 엔드포인트 변경 * fix: 사용자가 시작한 미션 목록 조회 API 엔드포인트 변경 * refactor: 리스트를 반환하는 레퍼지토리 메서드 이름 변경 * test: 잘못된 엔드포인트로 Api 테스트하는 코드 수정 * fix: 중복된 메서드 선언 제거 --- .../src/main/java/develup/api/MissionApi.java | 8 ++++++ .../application/mission/MissionService.java | 12 +++++++++ .../domain/solution/SolutionRepository.java | 4 +-- .../test/java/develup/api/MissionApiTest.java | 26 +++++++++++++++++++ .../mission/MissionServiceTest.java | 26 +++++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/develup/api/MissionApi.java b/backend/src/main/java/develup/api/MissionApi.java index 641b8961e..c0df343f7 100644 --- a/backend/src/main/java/develup/api/MissionApi.java +++ b/backend/src/main/java/develup/api/MissionApi.java @@ -32,6 +32,14 @@ public ResponseEntity>> getMissions() { return ResponseEntity.ok(new ApiResponse<>(responses)); } + @GetMapping("/missions/in-progress") + @Operation(summary = "사용자가 시작한 미션 목록 조회 API", description = "사용자가 시작한 미션 목록을 조회합니다.") + public ResponseEntity>> getInProgressMissions(@Auth Accessor accessor) { + List responses = missionService.getInProgressMissions(accessor.id()); + + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + @GetMapping("/missions/{missionId}") @Operation( summary = "미션 조회 API", diff --git a/backend/src/main/java/develup/application/mission/MissionService.java b/backend/src/main/java/develup/application/mission/MissionService.java index 61f52acc9..291c68d23 100644 --- a/backend/src/main/java/develup/application/mission/MissionService.java +++ b/backend/src/main/java/develup/application/mission/MissionService.java @@ -6,11 +6,14 @@ import develup.application.auth.Accessor; import develup.domain.mission.Mission; import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; import develup.domain.solution.SolutionRepository; import develup.domain.solution.SolutionStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Transactional public class MissionService { private final MissionRepository missionRepository; @@ -27,6 +30,15 @@ public List getMissions() { .toList(); } + public List getInProgressMissions(Long memberId) { + return solutionRepository.findAllByMember_IdAndStatus(memberId, SolutionStatus.IN_PROGRESS) + .stream() + .map(Solution::getMission) + .distinct() + .map(MissionResponse::from) + .toList(); + } + public MissionWithStartedResponse getMission(Accessor accessor, Long missionId) { Mission mission = missionRepository.findById(missionId) .orElseThrow(() -> new DevelupException(ExceptionType.MISSION_NOT_FOUND)); diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepository.java b/backend/src/main/java/develup/domain/solution/SolutionRepository.java index 998368965..edcbdb5eb 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepository.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepository.java @@ -17,7 +17,7 @@ public interface SolutionRepository extends JpaRepository { """) List findCompletedSummaries(); - Optional findByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); + List findAllByMember_IdAndStatus(Long memberId, SolutionStatus status); - List findAllByMember_IdAndStatus(Long memberId, SolutionStatus solutionStatus); + Optional findByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); } diff --git a/backend/src/test/java/develup/api/MissionApiTest.java b/backend/src/test/java/develup/api/MissionApiTest.java index a5064366b..a5fde22ec 100644 --- a/backend/src/test/java/develup/api/MissionApiTest.java +++ b/backend/src/test/java/develup/api/MissionApiTest.java @@ -36,9 +36,11 @@ void getMissions() throws Exception { .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속"))) .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[0].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[0].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data[1].title", equalTo("루터회관 흡연단속"))) .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[1].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.length()", is(2))); } @@ -61,4 +63,28 @@ void getMission() throws Exception { equalTo("https://raw.githubusercontent.com/develup-mission/java-smoking/main/README.md"))) .andExpect(jsonPath("$.data.isStarted", is(false))); } + + @Test + @DisplayName("사용자가 시작한 미션 목록을 조회한다.") + void getInProgressMissions() throws Exception { + List responses = List.of( + MissionResponse.from(MissionTestData.defaultMission().build()), + MissionResponse.from(MissionTestData.defaultMission().build()) + ); + BDDMockito.given(missionService.getInProgressMissions(any())) + .willReturn(responses); + + mockMvc.perform(get("/missions/in-progress")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[0].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[0].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[1].title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[1].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data.length()", is(2))); + } } diff --git a/backend/src/test/java/develup/application/mission/MissionServiceTest.java b/backend/src/test/java/develup/application/mission/MissionServiceTest.java index b32e9af0b..dcdeb6a02 100644 --- a/backend/src/test/java/develup/application/mission/MissionServiceTest.java +++ b/backend/src/test/java/develup/application/mission/MissionServiceTest.java @@ -11,6 +11,7 @@ import develup.domain.member.MemberRepository; import develup.domain.mission.Mission; import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; import develup.domain.solution.SolutionRepository; import develup.support.IntegrationTestSupport; import develup.support.data.MemberTestData; @@ -90,4 +91,29 @@ void getMission_started() { assertThat(response.isStarted()).isTrue(); } + + @Test + @DisplayName("사용자가 시작한 미션 목록을 조회한다.") + void getInProgressMissions() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(IN_PROGRESS) + .build(); + + Mission otherMission = missionRepository.save(MissionTestData.defaultMission().withTitle("다른 미션").build()); + Solution otherSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(otherMission) + .withStatus(IN_PROGRESS) + .build(); + + solutionRepository.saveAll(List.of(solution, otherSolution)); + List inProgressMissions = missionService.getInProgressMissions(member.getId()); + + assertThat(inProgressMissions).hasSize(2); + } } From 88305ff164bb77dd30683a273c42eee46444b398 Mon Sep 17 00:00:00 2001 From: Minsu Kim Date: Thu, 15 Aug 2024 20:29:22 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EC=86=94=EB=A3=A8=EC=85=98=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20API=20=EA=B5=AC=ED=98=84=20(issue=20#269)=20(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: entity 추상 클래스 작성(id, createdAt) * feat: solution comment entity 작성 * test: SolutionCommentTestData 작성 * feat: SolutionComment 도메인 댓글, 답글, 삭제 기능 작성 * feat: SolutionCommentApi 응답 값 및 기본 코드 작성 * feat: 댓글 삭제 서비스 작성 * feat: 댓글과 답글 생성 서비스 로직 작성 * chore: 메서드 라인 정렬 * refacotr: 삭제된 댓글은 조회할 수 없게 변경 * feat: solution comment grouping service 작성 * refactor: content column TEXT로 지정 * feat: comment에 solution_id index 추가 * refactor: 메서드 이름 변경 * test: Solution Comment Mapper 매핑 테스트 * test: comment grouping service test * chore: 메서드 줄 정렬 * test: solution comment api test * chore: print 제거 * refactor: solution comment fk는 mysql innodb에서 자동으로 index 생성해줘서 인덱스 제거 * refactor: createRepliesMapByRootCommentId 메서드 개선 * chore: 솔루션 댓글 초기 데이터 추가 * refactor: given when then 제거 * refactor: 일관성을 위해 assertSoftly 제거 * refactor: 테스트 수정 * refactor: return 위 개행 * refactor: 사용하지 않는 응답 dto 제거 및 로직 개선 * refactor: 정적 팩토리 컨벤션에 맞게 수정 * refactor: 메서드에 static 제거 * refactor: JpaAuditingConfig /infra/jpa로 폴더명 변경 --- .../java/develup/api/SolutionCommentApi.java | 67 +++++++ .../develup/api/exception/ExceptionType.java | 5 +- .../comment/CommentGroupingService.java | 57 ++++++ .../CreateSolutionCommentResponse.java | 31 ++++ .../SolutionCommentRepliesResponse.java | 60 +++++++ .../comment/SolutionCommentRequest.java | 9 + .../comment/SolutionCommentService.java | 99 ++++++++++ .../comment/SolutionReplyResponse.java | 26 +++ .../domain/CreatedAtAuditableEntity.java | 28 +++ .../develup/domain/IdentifiableEntity.java | 45 +++++ .../solution/comment/SolutionComment.java | 142 +++++++++++++++ .../comment/SolutionCommentRepository.java | 9 + .../develup/infra/jpa/JpaAuditingConfig.java | 9 + backend/src/main/resources/data.sql | 48 ++++- .../test/java/develup/api/ApiTestSupport.java | 8 + .../test/java/develup/api/MissionApiTest.java | 3 +- .../java/develup/api/SolutionApiTest.java | 5 - .../develup/api/SolutionCommentApiTest.java | 117 ++++++++++++ .../comment/CommentGroupingServiceTest.java | 75 ++++++++ .../SolutionCommentRepliesResponseTest.java | 66 +++++++ .../comment/SolutionCommentServiceTest.java | 170 ++++++++++++++++++ .../solution/comment/SolutionCommentTest.java | 100 +++++++++++ .../support/data/SolutionCommentTestData.java | 70 ++++++++ 23 files changed, 1233 insertions(+), 16 deletions(-) create mode 100644 backend/src/main/java/develup/api/SolutionCommentApi.java create mode 100644 backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java create mode 100644 backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java create mode 100644 backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java create mode 100644 backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java create mode 100644 backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java create mode 100644 backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java create mode 100644 backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java create mode 100644 backend/src/main/java/develup/domain/IdentifiableEntity.java create mode 100644 backend/src/main/java/develup/domain/solution/comment/SolutionComment.java create mode 100644 backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java create mode 100644 backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java create mode 100644 backend/src/test/java/develup/api/SolutionCommentApiTest.java create mode 100644 backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java create mode 100644 backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java create mode 100644 backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java create mode 100644 backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java create mode 100644 backend/src/test/java/develup/support/data/SolutionCommentTestData.java diff --git a/backend/src/main/java/develup/api/SolutionCommentApi.java b/backend/src/main/java/develup/api/SolutionCommentApi.java new file mode 100644 index 000000000..837e378ee --- /dev/null +++ b/backend/src/main/java/develup/api/SolutionCommentApi.java @@ -0,0 +1,67 @@ +package develup.api; + +import java.net.URI; +import java.util.List; +import develup.api.auth.Auth; +import develup.api.common.ApiResponse; +import develup.application.auth.Accessor; +import develup.application.solution.comment.CreateSolutionCommentResponse; +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; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RestController; + +@RestController +@Tag(name = "솔루션 댓글 API") +public class SolutionCommentApi { + + private final SolutionCommentService solutionCommentService; + + public SolutionCommentApi(SolutionCommentService solutionCommentService) { + this.solutionCommentService = solutionCommentService; + } + + @GetMapping("/solutions/{solutionId}/comments") + @Operation(summary = "솔루션 댓글 조회 API", description = "솔루션의 댓글 목록을 조회합니다. 댓글들과 댓글들에 대한 답글을 조회합니다.") + public ResponseEntity>> getComments( + @PathVariable Long solutionId + ) { + List responses = solutionCommentService.getCommentsWithReplies(solutionId); + + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + + @PostMapping("/solutions/{solutionId}/comments") + @Operation(summary = "솔루션 댓글 추가 API", description = "솔루션에 댓글을 추가합니다. 부모 댓글 식별자로 답글을 추가할 수 있습니다.") + public ResponseEntity> addComment( + @PathVariable Long solutionId, + @Valid @RequestBody SolutionCommentRequest request, + @Auth Accessor accessor + ) { + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, accessor.id()); + + URI location = URI.create("/solutions/" + response.solutionId() + "/comments/" + response.id()); + + return ResponseEntity.created(location).body(new ApiResponse<>(response)); + } + + @DeleteMapping("/solutions/comments/{commentId}") + @Operation(summary = "솔루션 댓글 삭제 API", description = "솔루션의 댓글을 삭제합니다.") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @Auth Accessor accessor + ) { + solutionCommentService.deleteComment(commentId, accessor.id()); + + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/develup/api/exception/ExceptionType.java b/backend/src/main/java/develup/api/exception/ExceptionType.java index 81191abea..b1f36470c 100644 --- a/backend/src/main/java/develup/api/exception/ExceptionType.java +++ b/backend/src/main/java/develup/api/exception/ExceptionType.java @@ -17,7 +17,10 @@ public enum ExceptionType { SOLUTION_ALREADY_SUBMITTED(HttpStatus.BAD_REQUEST, "이미 제출한 미션입니다."), INVALID_URL(HttpStatus.BAD_REQUEST, "올바르지 않은 주소입니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "올바르지 않은 제목입니다."), - ; + COMMENT_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + CANNOT_REPLY_TO_REPLY(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + COMMENT_NOT_WRITTEN_BY_MEMBER(HttpStatus.FORBIDDEN, "작성자만 댓글을 삭제할 수 있습니다."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java b/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java new file mode 100644 index 000000000..f1fa9d8e8 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java @@ -0,0 +1,57 @@ +package develup.application.solution.comment; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import develup.domain.solution.comment.SolutionComment; +import org.springframework.stereotype.Service; + +@Service +public class CommentGroupingService { + + public List groupReplies(List comments) { + List rootComments = filterRootComments(comments); + + Map> repliesMap = createRepliesMapByRootCommentId(comments); + List commentWithReplies = attachRepliesToRootComments(rootComments, repliesMap); + + return commentWithReplies.stream() + .filter(this::isRootCommentNotDeletedOrHasReplies) + .toList(); + } + + private List filterRootComments(List comments) { + return comments.stream() + .filter(SolutionComment::isRootComment) + .toList(); + } + + private Map> createRepliesMapByRootCommentId(List comments) { + return comments.stream() + .filter(SolutionComment::isReply) + .filter(SolutionComment::isNotDeleted) + .collect(Collectors.groupingBy(SolutionComment::getParentCommentId)); + } + + private List attachRepliesToRootComments( + List rootComments, + Map> repliesMap + ) { + return rootComments.stream() + .map(rootComment -> createSolutionCommentRepliesResponse(rootComment, repliesMap)) + .toList(); + } + + private SolutionCommentRepliesResponse createSolutionCommentRepliesResponse( + SolutionComment rootComment, + Map> repliesMap + ) { + List replies = repliesMap.getOrDefault(rootComment.getId(), List.of()); + + return SolutionCommentRepliesResponse.of(rootComment, replies); + } + + private boolean isRootCommentNotDeletedOrHasReplies(SolutionCommentRepliesResponse rootCommentResponse) { + return !rootCommentResponse.isDeleted() || !rootCommentResponse.replies().isEmpty(); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java b/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java new file mode 100644 index 000000000..82867cfad --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java @@ -0,0 +1,31 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import java.util.Optional; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record CreateSolutionCommentResponse( + Long id, + Long solutionId, + Long parentCommentId, + String content, + MemberResponse member, + LocalDateTime createdAt +) { + + public static CreateSolutionCommentResponse from(SolutionComment comment) { + Long parentCommentId = Optional.ofNullable(comment.getParentComment()) + .map(SolutionComment::getId) + .orElse(null); + + return new CreateSolutionCommentResponse( + comment.getId(), + comment.getSolutionId(), + parentCommentId, + comment.getContent(), + MemberResponse.from(comment.getMember()), + comment.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java new file mode 100644 index 000000000..dcf3aa402 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java @@ -0,0 +1,60 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record SolutionCommentRepliesResponse( + Long id, + Long solutionId, + String content, + MemberResponse member, + List replies, + LocalDateTime createdAt, + boolean isDeleted +) { + + private static final LocalDateTime EPOCH_TIME = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC); + private static final MemberResponse EMPTY_MEMBER = new MemberResponse(0L, "", "", ""); + + public static SolutionCommentRepliesResponse of( + SolutionComment rootComment, + List replies + ) { + List replyResponses = replies.stream() + .map(SolutionReplyResponse::from) + .toList(); + + + if (rootComment.isDeleted()) { + return ofDeleted(rootComment, replyResponses); + } + + return new SolutionCommentRepliesResponse( + rootComment.getId(), + rootComment.getSolutionId(), + rootComment.getContent(), + MemberResponse.from(rootComment.getMember()), + replyResponses, + rootComment.getCreatedAt(), + false + ); + } + + private static SolutionCommentRepliesResponse ofDeleted( + SolutionComment rootComment, + List replyResponses + ) { + return new SolutionCommentRepliesResponse( + rootComment.getId(), + rootComment.getSolutionId(), + "", + EMPTY_MEMBER, + replyResponses, + EPOCH_TIME, + true + ); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java new file mode 100644 index 000000000..a38d3898d --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java @@ -0,0 +1,9 @@ +package develup.application.solution.comment; + +import jakarta.validation.constraints.NotBlank; + +public record SolutionCommentRequest( + @NotBlank String content, + Long parentCommentId +) { +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java new file mode 100644 index 000000000..4db638cd0 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java @@ -0,0 +1,99 @@ +package develup.application.solution.comment; + +import java.util.List; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.member.Member; +import develup.domain.member.MemberRepository; +import develup.domain.solution.Solution; +import develup.domain.solution.SolutionRepository; +import develup.domain.solution.comment.SolutionComment; +import develup.domain.solution.comment.SolutionCommentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class SolutionCommentService { + + private final CommentGroupingService commentGroupingService; + private final SolutionCommentRepository solutionCommentRepository; + private final MemberRepository memberRepository; + private final SolutionRepository solutionRepository; + + public SolutionCommentService( + CommentGroupingService commentGroupingService, + SolutionCommentRepository solutionCommentRepository, + MemberRepository memberRepository, + SolutionRepository solutionRepository + ) { + this.commentGroupingService = commentGroupingService; + this.solutionCommentRepository = solutionCommentRepository; + this.memberRepository = memberRepository; + this.solutionRepository = solutionRepository; + } + + public SolutionComment getComment(Long commentId) { + SolutionComment comment = solutionCommentRepository.findById(commentId) + .orElseThrow(() -> new DevelupException(ExceptionType.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_NOT_FOUND); + } + + return comment; + } + + public List getCommentsWithReplies(Long solutionId) { + List comments = solutionCommentRepository.findAllBySolution_IdOrderByCreatedAtAsc(solutionId); + + return commentGroupingService.groupReplies(comments); + } + + public CreateSolutionCommentResponse addComment(Long solutionId, SolutionCommentRequest request, Long memberId) { + Member member = getMember(memberId); + Solution solution = getSolution(solutionId); + + boolean isReply = request.parentCommentId() != null; + if (isReply) { + SolutionComment reply = createReply(request, member); + return CreateSolutionCommentResponse.from(reply); + } + + SolutionComment rootComment = createRootComment(request, solution, member); + return CreateSolutionCommentResponse.from(rootComment); + } + + private SolutionComment createReply(SolutionCommentRequest request, Member member) { + SolutionComment parentComment = getComment(request.parentCommentId()); + SolutionComment reply = parentComment.reply(request.content(), member); + + return solutionCommentRepository.save(reply); + } + + private SolutionComment createRootComment(SolutionCommentRequest request, Solution solution, Member member) { + SolutionComment rootComment = SolutionComment.create(request.content(), solution, member); + + return solutionCommentRepository.save(rootComment); + } + + public void deleteComment(Long commentId, Long memberId) { + SolutionComment comment = getComment(commentId); + + if (comment.isNotWrittenBy(memberId)) { + throw new DevelupException(ExceptionType.COMMENT_NOT_WRITTEN_BY_MEMBER); + } + + comment.delete(); + } + + private Member getMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new DevelupException(ExceptionType.MEMBER_NOT_FOUND)); + } + + private Solution getSolution(Long solutionId) { + return solutionRepository.findById(solutionId) + .orElseThrow(() -> new DevelupException(ExceptionType.SOLUTION_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java b/backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java new file mode 100644 index 000000000..935cca65f --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java @@ -0,0 +1,26 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record SolutionReplyResponse( + Long id, + Long solutionId, + Long parentCommentId, + String content, + MemberResponse member, + LocalDateTime createdAt +) { + + public static SolutionReplyResponse from(SolutionComment reply) { + return new SolutionReplyResponse( + reply.getId(), + reply.getSolutionId(), + reply.getParentCommentId(), + reply.getContent(), + MemberResponse.from(reply.getMember()), + reply.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java b/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java new file mode 100644 index 000000000..1673c24d0 --- /dev/null +++ b/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java @@ -0,0 +1,28 @@ +package develup.domain; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class CreatedAtAuditableEntity extends IdentifiableEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + protected LocalDateTime createdAt; + + protected CreatedAtAuditableEntity() { + } + + protected CreatedAtAuditableEntity(Long id) { + super(id); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/develup/domain/IdentifiableEntity.java b/backend/src/main/java/develup/domain/IdentifiableEntity.java new file mode 100644 index 000000000..056105832 --- /dev/null +++ b/backend/src/main/java/develup/domain/IdentifiableEntity.java @@ -0,0 +1,45 @@ +package develup.domain; + +import java.util.Objects; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import org.hibernate.Hibernate; + +@MappedSuperclass +public abstract class IdentifiableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + protected IdentifiableEntity() { + this(null); + } + + protected IdentifiableEntity(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + IdentifiableEntity that = (IdentifiableEntity) o; + return Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getId()); + } +} diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java new file mode 100644 index 000000000..2c4693701 --- /dev/null +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java @@ -0,0 +1,142 @@ +package develup.domain.solution.comment; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.CreatedAtAuditableEntity; +import develup.domain.member.Member; +import develup.domain.solution.Solution; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +@Entity +public class SolutionComment extends CreatedAtAuditableEntity { + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "solution_id", nullable = false) + private Solution solution; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private SolutionComment parentComment; + + @Column + private LocalDateTime deletedAt; + + protected SolutionComment() { + } + + public SolutionComment( + String content, + Solution solution, + Member member, + SolutionComment parentComment, + LocalDateTime deletedAt + ) { + this(null, content, solution, member, parentComment, deletedAt); + } + + public SolutionComment( + Long id, + String content, + Solution solution, + Member member, + SolutionComment parentComment, + LocalDateTime deletedAt + ) { + super(id); + this.content = content; + this.solution = solution; + this.member = member; + this.parentComment = parentComment; + this.deletedAt = deletedAt; + } + + public static SolutionComment create(String content, Solution solution, Member member) { + return new SolutionComment(content, solution, member, null, null); + } + + public SolutionComment reply(String content, Member member) { + if (this.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_ALREADY_DELETED); + } + + if (this.isReply()) { + throw new DevelupException(ExceptionType.CANNOT_REPLY_TO_REPLY); + } + + SolutionComment reply = new SolutionComment(); + reply.content = content; + reply.solution = this.solution; + reply.member = member; + reply.parentComment = this; + + return reply; + } + + public void delete() { + if (this.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_ALREADY_DELETED); + } + + this.deletedAt = LocalDateTime.now(); + } + + public String getContent() { + return content; + } + + public Solution getSolution() { + return solution; + } + + public Long getSolutionId() { + return solution.getId(); + } + + public Member getMember() { + return member; + } + + public boolean isNotWrittenBy(Long memberId) { + return !member.getId().equals(memberId); + } + + public SolutionComment getParentComment() { + return parentComment; + } + + public Long getParentCommentId() { + return parentComment.getId(); + } + + public boolean isRootComment() { + return parentComment == null; + } + + public boolean isReply() { + return parentComment != null; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public boolean isNotDeleted() { + return deletedAt == null; + } + + public boolean isDeleted() { + return deletedAt != null; + } +} diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java new file mode 100644 index 000000000..7cdc0b651 --- /dev/null +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java @@ -0,0 +1,9 @@ +package develup.domain.solution.comment; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SolutionCommentRepository extends JpaRepository { + + List findAllBySolution_IdOrderByCreatedAtAsc(Long solutionId); +} diff --git a/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java b/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java new file mode 100644 index 000000000..377e20d50 --- /dev/null +++ b/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package develup.infra.jpa; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 63b3441a3..77374f19b 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -8,13 +8,45 @@ INSERT INTO member (email, provider, social_id, name, image_url) VALUES ('test1@gmail.com', 'GITHUB', '1234', '아톰', 'www.naver.com'); 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'); +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 ('java-guessing-number', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', '숫자를 맞춰보자', 'https://github.com/develup-mission/java-guessing-number'); +VALUES ('java-guessing-number', + 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', '숫자를 맞춰보자', + 'https://github.com/develup-mission/java-guessing-number'); + +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'); + +-- root-1 +-- ㄴ root-1-1 +-- ㄴ root-1-2 +-- root-2 (deleted, view o) +-- ㄴ root-2-1 +-- ㄴ 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'); -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'); diff --git a/backend/src/test/java/develup/api/ApiTestSupport.java b/backend/src/test/java/develup/api/ApiTestSupport.java index f4c4ba007..86441786c 100644 --- a/backend/src/test/java/develup/api/ApiTestSupport.java +++ b/backend/src/test/java/develup/api/ApiTestSupport.java @@ -1,11 +1,13 @@ package develup.api; +import com.fasterxml.jackson.databind.ObjectMapper; import develup.api.auth.AuthArgumentResolver; import develup.api.auth.CookieAuthorizationExtractor; import develup.application.auth.AuthService; import develup.application.member.MemberService; import develup.application.mission.MissionService; import develup.application.solution.SolutionService; +import develup.application.solution.comment.SolutionCommentService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -17,6 +19,9 @@ public class ApiTestSupport { @Autowired protected MockMvc mockMvc; + @Autowired + protected ObjectMapper objectMapper; + @MockBean protected AuthService authService; @@ -29,6 +34,9 @@ public class ApiTestSupport { @MockBean protected SolutionService solutionService; + @MockBean + protected SolutionCommentService solutionCommentService; + @MockBean protected CookieAuthorizationExtractor cookieAuthorizationExtractor; diff --git a/backend/src/test/java/develup/api/MissionApiTest.java b/backend/src/test/java/develup/api/MissionApiTest.java index a5fde22ec..accbd7085 100644 --- a/backend/src/test/java/develup/api/MissionApiTest.java +++ b/backend/src/test/java/develup/api/MissionApiTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; 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; @@ -49,7 +48,7 @@ void getMissions() throws Exception { void getMission() throws Exception { Mission mission = MissionTestData.defaultMission().withId(1L).build(); MissionWithStartedResponse response = MissionWithStartedResponse.of(mission, false); - BDDMockito.given(missionService.getMission(any(), anyLong())) + BDDMockito.given(missionService.getMission(any(), any())) .willReturn(response); mockMvc.perform(get("/missions/1")) diff --git a/backend/src/test/java/develup/api/SolutionApiTest.java b/backend/src/test/java/develup/api/SolutionApiTest.java index 3226d4bfa..05890ac2b 100644 --- a/backend/src/test/java/develup/api/SolutionApiTest.java +++ b/backend/src/test/java/develup/api/SolutionApiTest.java @@ -10,7 +10,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; -import com.fasterxml.jackson.databind.ObjectMapper; import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionResponse; import develup.application.solution.StartSolutionRequest; @@ -23,14 +22,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; class SolutionApiTest extends ApiTestSupport { - @Autowired - private ObjectMapper objectMapper; - @Test @DisplayName("솔루션 목록을 조회한다.") void getSolutions() throws Exception { diff --git a/backend/src/test/java/develup/api/SolutionCommentApiTest.java b/backend/src/test/java/develup/api/SolutionCommentApiTest.java new file mode 100644 index 000000000..e457f9edc --- /dev/null +++ b/backend/src/test/java/develup/api/SolutionCommentApiTest.java @@ -0,0 +1,117 @@ +package develup.api; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.time.LocalDateTime; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.application.solution.comment.CreateSolutionCommentResponse; +import develup.application.solution.comment.SolutionCommentRepliesResponse; +import develup.application.solution.comment.SolutionCommentRequest; +import develup.application.solution.comment.SolutionReplyResponse; +import develup.domain.member.Member; +import develup.support.data.MemberTestData; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.http.MediaType; + +class SolutionCommentApiTest extends ApiTestSupport { + + @Test + @DisplayName("댓글 목록을 조회한다.") + void getComments() throws Exception { + SolutionReplyResponse replyResponse = createReplyResponse(); + List replyResponses = List.of(replyResponse); + List responses = List.of(createRootCommentResponse(replyResponses)); + + BDDMockito.given(solutionCommentService.getCommentsWithReplies(any())) + .willReturn(responses); + + mockMvc.perform( + get("/solutions/1/comments")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].content", equalTo("content"))) + .andExpect(jsonPath("$.data[0].replies", hasSize(1))) + .andExpect(jsonPath("$.data[0].replies[0].id", equalTo(2))); + } + + @Test + @DisplayName("댓글을 추가한다.") + void addComment() throws Exception { + Member member = MemberTestData.defaultMember().withId(1L).build(); + MemberResponse memberResponse = MemberResponse.from(member); + CreateSolutionCommentResponse response = new CreateSolutionCommentResponse( + 1L, + 1L, + null, + "content", + memberResponse, + LocalDateTime.now() + ); + BDDMockito.given(solutionCommentService.addComment(any(), any(), any())) + .willReturn(response); + + SolutionCommentRequest request = new SolutionCommentRequest("content", null); + mockMvc.perform( + post("/solutions/1/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id", equalTo(1))) + .andExpect(jsonPath("$.data.content", equalTo("content"))) + .andExpect(jsonPath("$.data.parentCommentId", equalTo(null))) + .andExpect(jsonPath("$.data.member.id", equalTo(1))) + .andExpect(jsonPath("$.data.createdAt").exists()); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() throws Exception { + BDDMockito.doNothing() + .when(solutionCommentService) + .deleteComment(any(), any()); + + mockMvc.perform( + delete("/solutions/comments/1") + .cookie(new Cookie("token", "mock_token")) + ) + .andDo(print()) + .andExpect(status().isNoContent()); + } + + private SolutionCommentRepliesResponse createRootCommentResponse(List replyResponses) { + return new SolutionCommentRepliesResponse( + 1L, + 1L, + "content", + MemberResponse.from(MemberTestData.defaultMember().withId(1L).build()), + replyResponses, + LocalDateTime.now(), + false + ); + } + + private SolutionReplyResponse createReplyResponse() { + return new SolutionReplyResponse( + 2L, + 1L, + 1L, + "reply", + MemberResponse.from(MemberTestData.defaultMember().withId(1L).build()), + LocalDateTime.now() + ); + } +} diff --git a/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java b/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java new file mode 100644 index 000000000..989b11f2c --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java @@ -0,0 +1,75 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import java.util.List; +import develup.domain.solution.comment.SolutionComment; +import develup.support.IntegrationTestSupport; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class CommentGroupingServiceTest extends IntegrationTestSupport { + + @Autowired + private CommentGroupingService commentGroupingService; + + /** + * [계층 구조] + * root1 + * - root1reply1 (삭제됨 - 안보임) + * - root1reply2 + * root2 (삭제됨 - 보임) + * - root2reply1 + * - root2reply2 + */ + @Test + @DisplayName("댓글을 그룹화한다.") + void groupReplies() { + List comments = createComments(); + + List rootCommentResponses = commentGroupingService.groupReplies(comments); + + assertAll( + () -> assertThat(rootCommentResponses).hasSize(2), + () -> assertThat(rootCommentResponses.get(0).replies()).hasSize(1), + () -> assertThat(rootCommentResponses.get(1).replies()).hasSize(2), + () -> assertThat(rootCommentResponses.get(0).replies().get(0)).isNotNull(), + () -> assertThat(rootCommentResponses.get(1).isDeleted()).isTrue(), + () -> assertThat(rootCommentResponses.get(1).replies().get(0)).isNotNull(), + () -> assertThat(rootCommentResponses.get(1).replies().get(1)).isNotNull() + ); + } + + private List createComments() { + SolutionComment root1 = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .build(); + SolutionComment root2 = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withDeletedAt(LocalDateTime.now()) + .build(); + SolutionComment root1reply1 = SolutionCommentTestData.defaultSolutionComment() + .withId(3L) + .withDeletedAt(LocalDateTime.now()) + .withParentComment(root1) + .build(); + SolutionComment root1reply2 = SolutionCommentTestData.defaultSolutionComment() + .withId(4L) + .withParentComment(root1) + .build(); + SolutionComment root2reply1 = SolutionCommentTestData.defaultSolutionComment() + .withId(5L) + .withParentComment(root2) + .build(); + SolutionComment root2reply2 = SolutionCommentTestData.defaultSolutionComment() + .withId(6L) + .withParentComment(root2) + .build(); + + return List.of(root1, root2, root1reply1, root1reply2, root2reply1, root2reply2); + } +} diff --git a/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java b/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java new file mode 100644 index 000000000..4f05702c7 --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java @@ -0,0 +1,66 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SolutionCommentRepliesResponseTest { + + public static final MemberResponse EMPTY_MEMBER = new MemberResponse(0L, "", "", ""); + + @Test + @DisplayName("SolutionCommentRepliesResponse로 변환한다.") + void toSolutionCommentRepliesResponse() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withParentComment(rootComment) + .build(); + + SolutionCommentRepliesResponse rootCommentResponse = SolutionCommentRepliesResponse.of( + rootComment, + List.of(reply) + ); + + SolutionReplyResponse replyResponse = SolutionReplyResponse.from(reply); + assertAll( + () -> assertThat(rootCommentResponse.replies()).containsExactly(replyResponse), + () -> assertThat(rootCommentResponse.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("SolutionCommentRepliesResponse로 변환 시 삭제된 댓글인 경우 내용 숨김 처리한다.") + void hideContentWhenDeleted() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .withDeletedAt(LocalDateTime.now()) + .build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withParentComment(rootComment) + .build(); + + SolutionCommentRepliesResponse rootCommentResponse = SolutionCommentRepliesResponse.of( + rootComment, + List.of(reply) + ); + + SolutionReplyResponse replyResponse = SolutionReplyResponse.from(reply); + assertAll( + () -> assertThat(rootCommentResponse.replies()).containsExactly(replyResponse), + () -> assertThat(rootCommentResponse.isDeleted()).isTrue(), + () -> assertThat(rootCommentResponse.content()).isEmpty(), + () -> assertThat(rootCommentResponse.member()).isEqualTo(EMPTY_MEMBER) + ); + } +} diff --git a/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java b/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java new file mode 100644 index 000000000..150e51ad1 --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java @@ -0,0 +1,170 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.domain.member.Member; +import develup.domain.member.MemberRepository; +import develup.domain.mission.Mission; +import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; +import develup.domain.solution.SolutionRepository; +import develup.domain.solution.comment.SolutionComment; +import develup.domain.solution.comment.SolutionCommentRepository; +import develup.support.IntegrationTestSupport; +import develup.support.data.MemberTestData; +import develup.support.data.MissionTestData; +import develup.support.data.SolutionCommentTestData; +import develup.support.data.SolutionTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class SolutionCommentServiceTest extends IntegrationTestSupport { + + @Autowired + private SolutionCommentService solutionCommentService; + + @Autowired + private SolutionCommentRepository solutionCommentRepository; + + @Autowired + private SolutionRepository solutionRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MissionRepository missionRepository; + + @Test + @DisplayName("댓글을 조회한다.") + void getComment() { + SolutionComment solutionComment = createSolutionComment(); + + SolutionComment foundSolutionComment = solutionCommentService.getComment(solutionComment.getId()); + + assertThat(foundSolutionComment).isEqualTo(solutionComment); + } + + @Test + @DisplayName("댓글 조회 시 존재하지 않는 경우 예외가 발생한다.") + void getComment_notFound() { + Long unknownId = -1L; + + assertThatThrownBy(() -> solutionCommentService.getComment(unknownId)) + .isInstanceOf(DevelupException.class) + .hasMessage("존재하지 않는 댓글입니다."); + } + + @Test + @DisplayName("댓글 조회 시 삭제된 댓글일 경우 예외가 발생한다.") + void getCommentFailedWhenDeleted() { + Solution solution = createSolution(); + Member member = solution.getMember(); + SolutionComment deletedComment = SolutionCommentTestData.defaultSolutionComment() + .withSolution(solution) + .withMember(member) + .withDeletedAt(LocalDateTime.now()) + .build(); + solutionCommentRepository.save(deletedComment); + + Long commentId = deletedComment.getId(); + assertThatThrownBy(() -> solutionCommentService.getComment(commentId)) + .isInstanceOf(DevelupException.class) + .hasMessage("존재하지 않는 댓글입니다."); + } + + @Test + @DisplayName("댓글을 추가한다.") + void addComment() { + Solution solution = createSolution(); + Member member = solution.getMember(); + + Long solutionId = solution.getId(); + Long memberId = member.getId(); + SolutionCommentRequest request = new SolutionCommentRequest( + "댓글입니다.", + null + ); + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, memberId); + + assertAll( + () -> assertThat(solutionCommentRepository.findAll()).hasSize(1), + () -> assertThat(response.parentCommentId()).isNull() + ); + } + + @Test + @DisplayName("답글을 추가한다.") + void addReply() { + SolutionComment solutionComment = createSolutionComment(); + Member member = solutionComment.getMember(); + Solution solution = solutionComment.getSolution(); + + Long solutionId = solution.getId(); + Long memberId = member.getId(); + SolutionCommentRequest request = new SolutionCommentRequest( + "답글입니다.", + solutionComment.getId() + ); + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, memberId); + + assertAll( + () -> assertThat(solutionCommentRepository.findAll()).hasSize(2), + () -> assertThat(response.parentCommentId()).isEqualTo(solutionComment.getId()) + ); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() { + SolutionComment solutionComment = createSolutionComment(); + + Long memberId = solutionComment.getMember().getId(); + Long commentId = solutionComment.getId(); + solutionCommentService.deleteComment(commentId, memberId); + + assertThat(solutionCommentRepository.findById(commentId)) + .map(SolutionComment::isDeleted) + .hasValue(true); + } + + @Test + @DisplayName("댓글을 삭제 시 작성자가 아닌 경우 예외가 발생한다.") + void deleteComment_notWrittenBy() { + SolutionComment solutionComment = createSolutionComment(); + + Long nonWriterId = -1L; + Long commentId = solutionComment.getId(); + + assertThatThrownBy(() -> solutionCommentService.deleteComment(commentId, nonWriterId)) + .isInstanceOf(DevelupException.class) + .hasMessage("작성자만 댓글을 삭제할 수 있습니다."); + } + + private Solution createSolution() { + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMission(mission) + .withMember(member) + .build(); + + return solutionRepository.save(solution); + } + + private SolutionComment createSolutionComment() { + Solution solution = createSolution(); + SolutionComment solutionComment = SolutionCommentTestData.defaultSolutionComment() + .withSolution(solution) + .withMember(solution.getMember()) + .build(); + solutionCommentRepository.save(solutionComment); + + return solutionComment; + } +} diff --git a/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java new file mode 100644 index 000000000..0095dba6c --- /dev/null +++ b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java @@ -0,0 +1,100 @@ +package develup.domain.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.domain.member.Member; +import develup.support.data.MemberTestData; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SolutionCommentTest { + + @Test + @DisplayName("댓글을 생성할 수 있다.") + void create() { + String content = "댓글입니다."; + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment() + .withContent(content) + .build(); + + assertAll( + () -> assertThat(comment.getContent()).isEqualTo(content), + () -> assertThat(comment.getParentComment()).isNull(), + () -> assertThat(comment.getDeletedAt()).isNull() + ); + } + + @Test + @DisplayName("댓글을 삭제할 수 있다.") + void delete() { + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment().build(); + + comment.delete(); + + assertThat(comment.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("삭제된 댓글은 삭제할 수 없다.") + void deleteFailedWhenAlreadyDeleted() { + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment() + .withDeletedAt(LocalDateTime.now()) + .build(); + + assertThatThrownBy(comment::delete) + .isInstanceOf(DevelupException.class) + .hasMessage("이미 삭제된 댓글입니다."); + } + + @Test + @DisplayName("댓글에 답글을 달 수 있다.") + void reply() { + SolutionComment parentComment = SolutionCommentTestData.defaultSolutionComment().build(); + String content = "답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + SolutionComment reply = parentComment.reply(content, member); + + assertAll( + () -> assertThat(reply.getContent()).isEqualTo(content), + () -> assertThat(reply.getSolution()).isEqualTo(parentComment.getSolution()), + () -> assertThat(reply.getMember()).isEqualTo(member), + () -> assertThat(reply.getParentComment()).isEqualTo(parentComment), + () -> assertThat(reply.getDeletedAt()).isNull() + ); + } + + @Test + @DisplayName("삭제된 댓글에는 답글을 달 수 없다.") + void replyFailedWhenAlreadyDeleted() { + SolutionComment parentComment = SolutionCommentTestData.defaultSolutionComment() + .withDeletedAt(LocalDateTime.now()) + .build(); + String content = "답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + assertThatThrownBy(() -> parentComment.reply(content, member)) + .isInstanceOf(DevelupException.class) + .hasMessage("이미 삭제된 댓글입니다."); + } + + @Test + @DisplayName("답글에는 답글을 달 수 없다.") + void replyFailedWhenAlreadyReply() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment().build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withParentComment(rootComment) + .build(); + String content = "답글에 대한 답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + assertThatThrownBy(() -> reply.reply(content, member)) + .isInstanceOf(DevelupException.class) + .hasMessage("답글에는 답글을 작성할 수 없습니다."); + } +} diff --git a/backend/src/test/java/develup/support/data/SolutionCommentTestData.java b/backend/src/test/java/develup/support/data/SolutionCommentTestData.java new file mode 100644 index 000000000..0505c4b3b --- /dev/null +++ b/backend/src/test/java/develup/support/data/SolutionCommentTestData.java @@ -0,0 +1,70 @@ +package develup.support.data; + +import java.time.LocalDateTime; +import develup.domain.member.Member; +import develup.domain.solution.Solution; +import develup.domain.solution.comment.SolutionComment; + +public class SolutionCommentTestData { + + public static SolutionCommentBuilder defaultSolutionComment() { + return new SolutionCommentBuilder() + .withId(null) + .withContent("안녕하세요. 피드백 잘 부탁 드려요.") + .withSolution(SolutionTestData.defaultSolution().build()) + .withMember(MemberTestData.defaultMember().build()) + .withParentComment(null) + .withDeletedAt(null); + } + + public static class SolutionCommentBuilder { + + private Long id; + private String content; + private Solution solution; + private Member member; + private SolutionComment parentComment; + private LocalDateTime deletedAt; + + public SolutionCommentBuilder withId(Long id) { + this.id = id; + return this; + } + + public SolutionCommentBuilder withContent(String content) { + this.content = content; + return this; + } + + public SolutionCommentBuilder withSolution(Solution solution) { + this.solution = solution; + return this; + } + + public SolutionCommentBuilder withMember(Member member) { + this.member = member; + return this; + } + + public SolutionCommentBuilder withParentComment(SolutionComment parentComment) { + this.parentComment = parentComment; + return this; + } + + public SolutionCommentBuilder withDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + return this; + } + + public SolutionComment build() { + return new SolutionComment( + id, + content, + solution, + member, + parentComment, + deletedAt + ); + } + } +} From 6ac9daa5ed31a1c801b749ed7cadfd4954950c2a Mon Sep 17 00:00:00 2001 From: Haneul Lee Date: Thu, 15 Aug 2024 20:48:17 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B9=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(issue=20#273)=20(#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 해시태그 엔티티 구현 * feat: 미션 해시 태그 엔티티 연결 * feat: 해시태그 존재 미션 단건 조회 기능 구현 * refactor: mission hash tag 접근 제어자 변경 * feat: mission 단건 조회시 태그 내려주도록 구현 * fix: hashTag 페치 조인하도록 변경 * fix: generated value identity 사용하도록 변경 * feat: 단건조회시 태그와 함께 가져오는 쿼리 사용하도록 구현 * chore: data.sql tag 관련 더미 데이터 추가 * style: 코드 포맷팅 * feat: 해시태그 존재 모든 미션 조회 쿼리 구현 * feat: mission service에서 태그를 포함하는 미션 목록을 반환하도록 구현 * refactor: 해시태그 포함 미션 조회 단건 쿼리 메서드명 변경 * fix: mission response에서 hashTag 포함하도록 변경 * fix: solution summary를 summarized response로 변경 * feat: 완료된 전체 제출 조회 쿼리 구현 * feat: batchsize 적용 * feat: solution 목록에서 해시 태그 조회 가능하도록 구현 * fix: 완료된 풀이 전체 조회 쿼리 distinct 키워드 제거 * test: 완료된 솔루션 조회 테스트 데이터 추가 * fix: mission hash tag one to many set을 사용하도록 변경 - batch size 설정을 제거했다. - 중복을 제거하기 위해 set을 사용했다. - n + 1을 해결했다. - 순서를 보장하기 위해서 LinkedHashSet을 사용했다. * refactor: missionHashTags 일급 컬렉션으로 포장 * refactor: 미사용 의존 제거 * fix: hashtag api 응답 필드 hashTags로 변경 --- .../main/java/develup/api/SolutionApi.java | 12 +-- .../develup/api/exception/ExceptionType.java | 5 +- .../application/hashtag/HashTagResponse.java | 13 ++++ .../application/mission/MissionResponse.java | 15 +++- .../application/mission/MissionService.java | 4 +- .../mission/MissionWithStartedResponse.java | 15 +++- .../application/solution/SolutionService.java | 7 +- .../solution/SummarizedSolutionResponse.java | 30 +++++++ .../java/develup/domain/hashtag/HashTag.java | 56 +++++++++++++ .../domain/hashtag/HashTagRepository.java | 6 ++ .../java/develup/domain/mission/Mission.java | 30 +++++-- .../domain/mission/MissionHashTag.java | 51 ++++++++++++ .../domain/mission/MissionHashTags.java | 69 ++++++++++++++++ .../domain/mission/MissionRepository.java | 18 +++++ .../develup/domain/solution/Solution.java | 14 +++- .../domain/solution/SolutionRepository.java | 9 ++- .../domain/solution/SolutionSummary.java | 9 --- backend/src/main/resources/data.sql | 23 +++++- .../test/java/develup/api/MissionApiTest.java | 29 ++++++- .../java/develup/api/SolutionApiTest.java | 70 ++++++++++------- .../mission/MissionServiceTest.java | 31 ++++++-- .../domain/mission/MissionHashTagsTest.java | 78 +++++++++++++++++++ .../domain/mission/MissionRepositoryTest.java | 63 +++++++++++++++ .../solution/SolutionRepositoryTest.java | 16 +++- .../develup/support/data/HashTagTestData.java | 30 +++++++ .../develup/support/data/MissionTestData.java | 15 +++- 26 files changed, 628 insertions(+), 90 deletions(-) create mode 100644 backend/src/main/java/develup/application/hashtag/HashTagResponse.java create mode 100644 backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java create mode 100644 backend/src/main/java/develup/domain/hashtag/HashTag.java create mode 100644 backend/src/main/java/develup/domain/hashtag/HashTagRepository.java create mode 100644 backend/src/main/java/develup/domain/mission/MissionHashTag.java create mode 100644 backend/src/main/java/develup/domain/mission/MissionHashTags.java delete mode 100644 backend/src/main/java/develup/domain/solution/SolutionSummary.java create mode 100644 backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java create mode 100644 backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java create mode 100644 backend/src/test/java/develup/support/data/HashTagTestData.java diff --git a/backend/src/main/java/develup/api/SolutionApi.java b/backend/src/main/java/develup/api/SolutionApi.java index 44435c602..6ebdcb781 100644 --- a/backend/src/main/java/develup/api/SolutionApi.java +++ b/backend/src/main/java/develup/api/SolutionApi.java @@ -9,7 +9,7 @@ import develup.application.solution.SolutionService; import develup.application.solution.StartSolutionRequest; import develup.application.solution.SubmitSolutionRequest; -import develup.domain.solution.SolutionSummary; +import develup.application.solution.SummarizedSolutionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -54,18 +54,18 @@ public ResponseEntity> submitSolution( @GetMapping("/solutions") @Operation(summary = "솔루션 조회 목록 API", description = "솔루션 목록을 조회합니다.") - public ResponseEntity>> getSolutions() { - List summaries = solutionService.getCompletedSummaries(); + public ResponseEntity>> getSolutions() { + List responses = solutionService.getCompletedSummaries(); - return ResponseEntity.ok(new ApiResponse<>(summaries)); + return ResponseEntity.ok(new ApiResponse<>(responses)); } @GetMapping("/solutions/{id}") @Operation(summary = "솔루션 조회 API", description = "솔루션을 조회합니다.") public ResponseEntity> getSolution(@PathVariable Long id) { - SolutionResponse solutionResponse = solutionService.getById(id); + SolutionResponse response = solutionService.getById(id); - return ResponseEntity.ok(new ApiResponse<>(solutionResponse)); + return ResponseEntity.ok(new ApiResponse<>(response)); } @GetMapping("/solutions/mine") diff --git a/backend/src/main/java/develup/api/exception/ExceptionType.java b/backend/src/main/java/develup/api/exception/ExceptionType.java index b1f36470c..184097a6d 100644 --- a/backend/src/main/java/develup/api/exception/ExceptionType.java +++ b/backend/src/main/java/develup/api/exception/ExceptionType.java @@ -20,7 +20,10 @@ public enum ExceptionType { COMMENT_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), CANNOT_REPLY_TO_REPLY(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), - COMMENT_NOT_WRITTEN_BY_MEMBER(HttpStatus.FORBIDDEN, "작성자만 댓글을 삭제할 수 있습니다."); + COMMENT_NOT_WRITTEN_BY_MEMBER(HttpStatus.FORBIDDEN, "작성자만 댓글을 삭제할 수 있습니다."), + DUPLICATED_HASHTAG(HttpStatus.BAD_REQUEST, "중복된 해시태그입니다."), + + ; private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/develup/application/hashtag/HashTagResponse.java b/backend/src/main/java/develup/application/hashtag/HashTagResponse.java new file mode 100644 index 000000000..442d797d4 --- /dev/null +++ b/backend/src/main/java/develup/application/hashtag/HashTagResponse.java @@ -0,0 +1,13 @@ +package develup.application.hashtag; + +import develup.domain.hashtag.HashTag; + +public record HashTagResponse(Long id, String name) { + + public static HashTagResponse from(HashTag hashTag) { + return new HashTagResponse( + hashTag.getId(), + hashTag.getName() + ); + } +} diff --git a/backend/src/main/java/develup/application/mission/MissionResponse.java b/backend/src/main/java/develup/application/mission/MissionResponse.java index 7067fb1ee..1f48959f6 100644 --- a/backend/src/main/java/develup/application/mission/MissionResponse.java +++ b/backend/src/main/java/develup/application/mission/MissionResponse.java @@ -1,22 +1,33 @@ package develup.application.mission; +import java.util.List; +import develup.application.hashtag.HashTagResponse; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; public record MissionResponse( Long id, String title, String thumbnail, String summary, - String url + String url, + List hashTags ) { public static MissionResponse from(Mission mission) { + List hashTagResponses = mission.getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + return new MissionResponse( mission.getId(), mission.getTitle(), mission.getThumbnail(), mission.getSummary(), - mission.getUrl() + mission.getUrl(), + hashTagResponses ); } } diff --git a/backend/src/main/java/develup/application/mission/MissionService.java b/backend/src/main/java/develup/application/mission/MissionService.java index 291c68d23..3e97894c2 100644 --- a/backend/src/main/java/develup/application/mission/MissionService.java +++ b/backend/src/main/java/develup/application/mission/MissionService.java @@ -25,7 +25,7 @@ public MissionService(MissionRepository missionRepository, SolutionRepository so } public List getMissions() { - return missionRepository.findAll().stream() + return missionRepository.findAllHashTaggedMission().stream() .map(MissionResponse::from) .toList(); } @@ -40,7 +40,7 @@ public List getInProgressMissions(Long memberId) { } public MissionWithStartedResponse getMission(Accessor accessor, Long missionId) { - Mission mission = missionRepository.findById(missionId) + Mission mission = missionRepository.findHashTaggedMissionById(missionId) .orElseThrow(() -> new DevelupException(ExceptionType.MISSION_NOT_FOUND)); if (accessor.isGuest()) { diff --git a/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java b/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java index 43ac2b83e..a5b2814e8 100644 --- a/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java +++ b/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java @@ -1,6 +1,9 @@ package develup.application.mission; +import java.util.List; +import develup.application.hashtag.HashTagResponse; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; public record MissionWithStartedResponse( Long id, @@ -8,17 +11,25 @@ public record MissionWithStartedResponse( String descriptionUrl, String thumbnail, String url, - boolean isStarted + boolean isStarted, + List hashTags ) { public static MissionWithStartedResponse of(Mission mission, boolean isStarted) { + List hashTagResponses = mission.getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + return new MissionWithStartedResponse( mission.getId(), mission.getTitle(), mission.getDescriptionUrl(), mission.getThumbnail(), mission.getUrl(), - isStarted + isStarted, + hashTagResponses ); } diff --git a/backend/src/main/java/develup/application/solution/SolutionService.java b/backend/src/main/java/develup/application/solution/SolutionService.java index 52717a902..57639fbfb 100644 --- a/backend/src/main/java/develup/application/solution/SolutionService.java +++ b/backend/src/main/java/develup/application/solution/SolutionService.java @@ -13,7 +13,6 @@ import develup.domain.solution.SolutionRepository; import develup.domain.solution.SolutionStatus; import develup.domain.solution.SolutionSubmit; -import develup.domain.solution.SolutionSummary; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -104,8 +103,10 @@ public SolutionResponse getById(Long id) { return SolutionResponse.from(solution); } - public List getCompletedSummaries() { - return solutionRepository.findCompletedSummaries(); + public List getCompletedSummaries() { + return solutionRepository.findAllCompletedSolution().stream() + .map(SummarizedSolutionResponse::from) + .toList(); } public List getSubmittedSolutionsByMemberId(Long memberId) { diff --git a/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java b/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java new file mode 100644 index 000000000..b8122650d --- /dev/null +++ b/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java @@ -0,0 +1,30 @@ +package develup.application.solution; + +import java.util.List; +import develup.application.hashtag.HashTagResponse; +import develup.domain.mission.MissionHashTag; +import develup.domain.solution.Solution; + +public record SummarizedSolutionResponse( + Long id, + String title, + String thumbnail, + String description, + List hashTags +) { + + public static SummarizedSolutionResponse from(Solution solution) { + List hashTagResponses = solution.getHashTags().stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + + return new SummarizedSolutionResponse( + solution.getId(), + solution.getTitle(), + solution.getMissionThumbnail(), + solution.getDescription(), + hashTagResponses + ); + } +} diff --git a/backend/src/main/java/develup/domain/hashtag/HashTag.java b/backend/src/main/java/develup/domain/hashtag/HashTag.java new file mode 100644 index 000000000..ee3a5ef6c --- /dev/null +++ b/backend/src/main/java/develup/domain/hashtag/HashTag.java @@ -0,0 +1,56 @@ +package develup.domain.hashtag; + +import java.util.Objects; +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; + + @Column(nullable = false) + private String name; + + protected HashTag() { + } + + public HashTag(String name) { + this(null, name); + } + + public HashTag(Long id, String name) { + this.id = 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/hashtag/HashTagRepository.java b/backend/src/main/java/develup/domain/hashtag/HashTagRepository.java new file mode 100644 index 000000000..9fb8256cc --- /dev/null +++ b/backend/src/main/java/develup/domain/hashtag/HashTagRepository.java @@ -0,0 +1,6 @@ +package develup.domain.hashtag; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HashTagRepository extends JpaRepository { +} diff --git a/backend/src/main/java/develup/domain/mission/Mission.java b/backend/src/main/java/develup/domain/mission/Mission.java index 88e2a05b0..59bf29b1b 100644 --- a/backend/src/main/java/develup/domain/mission/Mission.java +++ b/backend/src/main/java/develup/domain/mission/Mission.java @@ -1,6 +1,10 @@ package develup.domain.mission; +import java.util.List; +import java.util.Set; +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; @@ -28,19 +32,33 @@ public class Mission { @Column(nullable = false) private String url; + @Embedded + private MissionHashTags missionHashTags; + protected Mission() { } - public Mission(String title, String thumbnail, String summary, String url) { - this(null, title, thumbnail, summary, url); + public Mission(String title, String thumbnail, String summary, String url, List hashTags) { + this(null, title, thumbnail, summary, url, hashTags); } - public Mission(Long id, String title, String thumbnail, String summary, String url) { + public Mission(Long id, String title, String thumbnail, String summary, String url, List hashTags) { this.id = id; this.title = title; this.thumbnail = thumbnail; this.summary = summary; this.url = url; + this.missionHashTags = new MissionHashTags(this, hashTags); + } + + public void tagAll(List tags) { + missionHashTags.addAll(this, tags); + } + + public String getDescriptionUrl() { + String[] split = url.split("/"); + + return DESCRIPTION_BASE_URL_PREFIX + split[split.length - 1] + DESCRIPTION_BASE_URL_SUFFIX; } public Long getId() { @@ -63,9 +81,7 @@ public String getUrl() { return url; } - public String getDescriptionUrl() { - String[] split = url.split("/"); - - return DESCRIPTION_BASE_URL_PREFIX + split[split.length - 1] + DESCRIPTION_BASE_URL_SUFFIX; + public Set getHashTags() { + return missionHashTags.getHashTags(); } } diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTag.java b/backend/src/main/java/develup/domain/mission/MissionHashTag.java new file mode 100644 index 000000000..2aa623c9a --- /dev/null +++ b/backend/src/main/java/develup/domain/mission/MissionHashTag.java @@ -0,0 +1,51 @@ +package develup.domain.mission; + +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; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private Mission mission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private HashTag hashTag; + + protected MissionHashTag() { + } + + public MissionHashTag(Mission mission, HashTag hashTag) { + this(null, mission, hashTag); + } + + public MissionHashTag(Long id, Mission mission, HashTag hashTag) { + this.id = id; + this.mission = mission; + this.hashTag = hashTag; + } + + public Long getId() { + return id; + } + + public Mission getMission() { + return mission; + } + + public HashTag getHashTag() { + return hashTag; + } +} diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTags.java b/backend/src/main/java/develup/domain/mission/MissionHashTags.java new file mode 100644 index 000000000..d077cb8c3 --- /dev/null +++ b/backend/src/main/java/develup/domain/mission/MissionHashTags.java @@ -0,0 +1,69 @@ +package develup.domain.mission; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.hashtag.HashTag; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; + +@Embeddable +class MissionHashTags { + + @OrderBy(value = "id ASC") + @OneToMany(mappedBy = "mission", cascade = CascadeType.PERSIST) + private Set hashTags = new LinkedHashSet<>(); + + protected MissionHashTags() { + } + + public MissionHashTags(Mission mission, List hashTags) { + validateDuplicated(hashTags); + + this.hashTags = mapToMissionHashTag(mission, hashTags); + } + + public void addAll(Mission target, List hashTags) { + validateDuplicated(hashTags); + validateAlreadyTagged(hashTags); + + this.hashTags.addAll(mapToMissionHashTag(target, hashTags)); + } + + private void validateDuplicated(List hashTags) { + int uniqueSize = hashTags.stream() + .distinct() + .toList() + .size(); + + if (uniqueSize != hashTags.size()) { + throw new DevelupException(ExceptionType.DUPLICATED_HASHTAG); + } + } + + private void validateAlreadyTagged(List hashTags) { + boolean alreadyTagged = this.hashTags.stream() + .map(MissionHashTag::getHashTag) + .anyMatch(hashTags::contains); + + if (alreadyTagged) { + throw new DevelupException(ExceptionType.DUPLICATED_HASHTAG); + } + } + + private Set mapToMissionHashTag(Mission target, List hashTags) { + return hashTags.stream() + .map(it -> new MissionHashTag(target, it)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + public Set getHashTags() { + return Collections.unmodifiableSet(hashTags); + } +} diff --git a/backend/src/main/java/develup/domain/mission/MissionRepository.java b/backend/src/main/java/develup/domain/mission/MissionRepository.java index ea1d68e43..d30d8d156 100644 --- a/backend/src/main/java/develup/domain/mission/MissionRepository.java +++ b/backend/src/main/java/develup/domain/mission/MissionRepository.java @@ -1,6 +1,7 @@ package develup.domain.mission; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -8,4 +9,21 @@ public interface MissionRepository extends JpaRepository { @Query("SELECT m.url FROM Mission m") List findUrl(); + + @Query(""" + SELECT DISTINCT m + FROM Mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht + WHERE m.id = :id + """) + Optional findHashTaggedMissionById(Long id); + + @Query(""" + SELECT DISTINCT m + FROM Mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht + """) + List findAllHashTaggedMission(); } diff --git a/backend/src/main/java/develup/domain/solution/Solution.java b/backend/src/main/java/develup/domain/solution/Solution.java index 8ce716463..fe3a6228e 100644 --- a/backend/src/main/java/develup/domain/solution/Solution.java +++ b/backend/src/main/java/develup/domain/solution/Solution.java @@ -1,9 +1,11 @@ package develup.domain.solution; +import java.util.Set; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.member.Member; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -103,10 +105,6 @@ public Mission getMission() { return mission; } - public String getMissionThumbnail() { - return mission.getThumbnail(); - } - public Member getMember() { return member; } @@ -126,4 +124,12 @@ public String getUrl() { public SolutionStatus getStatus() { return status; } + + public String getMissionThumbnail() { + return mission.getThumbnail(); + } + + public Set getHashTags() { + return mission.getHashTags(); + } } diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepository.java b/backend/src/main/java/develup/domain/solution/SolutionRepository.java index edcbdb5eb..3cf6e76e9 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepository.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepository.java @@ -10,12 +10,15 @@ public interface SolutionRepository extends JpaRepository { boolean existsByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); @Query(""" - SELECT new develup.domain.solution.SolutionSummary(s.id, m.thumbnail, s.title.value, s.description) + SELECT DISTINCT s FROM Solution s - JOIN s.mission m + JOIN FETCH s.mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht WHERE s.status = 'COMPLETED' + ORDER BY s.id DESC """) - List findCompletedSummaries(); + List findAllCompletedSolution(); List findAllByMember_IdAndStatus(Long memberId, SolutionStatus status); diff --git a/backend/src/main/java/develup/domain/solution/SolutionSummary.java b/backend/src/main/java/develup/domain/solution/SolutionSummary.java deleted file mode 100644 index 90c008cac..000000000 --- a/backend/src/main/java/develup/domain/solution/SolutionSummary.java +++ /dev/null @@ -1,9 +0,0 @@ -package develup.domain.solution; - -public record SolutionSummary( - Long id, - String thumbnail, - String title, - String description -) { -} diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 77374f19b..176366483 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -11,9 +11,25 @@ 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 ('java-guessing-number', - 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', '숫자를 맞춰보자', - 'https://github.com/develup-mission/java-guessing-number'); +VALUES ('숫자 맞추기 게임', '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 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 solution (mission_id, member_id, title, description, url, status) VALUES (1, 1, '릴리 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); @@ -49,4 +65,3 @@ INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id 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'); - diff --git a/backend/src/test/java/develup/api/MissionApiTest.java b/backend/src/test/java/develup/api/MissionApiTest.java index accbd7085..28b91ece8 100644 --- a/backend/src/test/java/develup/api/MissionApiTest.java +++ b/backend/src/test/java/develup/api/MissionApiTest.java @@ -11,7 +11,9 @@ import java.util.List; import develup.application.mission.MissionResponse; import develup.application.mission.MissionWithStartedResponse; +import develup.domain.hashtag.HashTag; import develup.domain.mission.Mission; +import develup.support.data.HashTagTestData; import develup.support.data.MissionTestData; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,9 +24,10 @@ class MissionApiTest extends ApiTestSupport { @Test @DisplayName("미션 목록을 조회한다.") void getMissions() throws Exception { + Mission mission = createMission(); List responses = List.of( - MissionResponse.from(MissionTestData.defaultMission().build()), - MissionResponse.from(MissionTestData.defaultMission().build()) + MissionResponse.from(mission), + MissionResponse.from(mission) ); BDDMockito.given(missionService.getMissions()) .willReturn(responses); @@ -36,17 +39,22 @@ void getMissions() throws Exception { .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[0].url", equalTo("https://github.com/develup-mission/java-smoking"))) .andExpect(jsonPath("$.data[0].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[0].hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data[1].title", equalTo("루터회관 흡연단속"))) .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[1].url", equalTo("https://github.com/develup-mission/java-smoking"))) .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[1].hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[1].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data.length()", is(2))); } @Test @DisplayName("미션을 조회한다.") void getMission() throws Exception { - Mission mission = MissionTestData.defaultMission().withId(1L).build(); + Mission mission = createMission(); MissionWithStartedResponse response = MissionWithStartedResponse.of(mission, false); BDDMockito.given(missionService.getMission(any(), any())) .willReturn(response); @@ -60,7 +68,9 @@ void getMission() throws Exception { .andExpect(jsonPath("$.data.url", equalTo("https://github.com/develup-mission/java-smoking"))) .andExpect(jsonPath("$.data.descriptionUrl", equalTo("https://raw.githubusercontent.com/develup-mission/java-smoking/main/README.md"))) - .andExpect(jsonPath("$.data.isStarted", is(false))); + .andExpect(jsonPath("$.data.isStarted", is(false))) + .andExpect(jsonPath("$.data.hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data.hashTags[0].name", equalTo("JAVA"))); } @Test @@ -86,4 +96,15 @@ void getInProgressMissions() throws Exception { .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.length()", is(2))); } + + private Mission createMission() { + HashTag hashTag = HashTagTestData.defaultHashTag() + .withId(1L) + .build(); + + return MissionTestData.defaultMission() + .withId(1L) + .withHashTags(List.of(hashTag)) + .build(); + } } diff --git a/backend/src/test/java/develup/api/SolutionApiTest.java b/backend/src/test/java/develup/api/SolutionApiTest.java index 05890ac2b..5bb1eef2d 100644 --- a/backend/src/test/java/develup/api/SolutionApiTest.java +++ b/backend/src/test/java/develup/api/SolutionApiTest.java @@ -14,8 +14,12 @@ import develup.application.solution.SolutionResponse; import develup.application.solution.StartSolutionRequest; import develup.application.solution.SubmitSolutionRequest; +import develup.application.solution.SummarizedSolutionResponse; +import develup.domain.hashtag.HashTag; +import develup.domain.member.Member; +import develup.domain.mission.Mission; import develup.domain.solution.Solution; -import develup.domain.solution.SolutionSummary; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -29,35 +33,29 @@ class SolutionApiTest extends ApiTestSupport { @Test @DisplayName("솔루션 목록을 조회한다.") void getSolutions() throws Exception { - List summaries = List.of( - new SolutionSummary(1L, "thumbnail", "value", "description"), - new SolutionSummary(2L, "thumbnail", "value", "description") + List responses = List.of( + SummarizedSolutionResponse.from(createSolution()), + SummarizedSolutionResponse.from(createSolution()) ); BDDMockito.given(solutionService.getCompletedSummaries()) - .willReturn(summaries); + .willReturn(responses); mockMvc.perform(get("/solutions")) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].id", equalTo(1))) - .andExpect(jsonPath("$.data[0].title", equalTo("value"))) - .andExpect(jsonPath("$.data[0].thumbnail", equalTo("thumbnail"))) - .andExpect(jsonPath("$.data[0].description", equalTo("description"))) - .andExpect(jsonPath("$.data[1].id", equalTo(2))) - .andExpect(jsonPath("$.data[1].title", equalTo("value"))) - .andExpect(jsonPath("$.data[1].thumbnail", equalTo("thumbnail"))) - .andExpect(jsonPath("$.data[1].description", equalTo("description"))) + .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속 제출합니다."))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[0].description", equalTo("안녕하세요. 피드백 잘 부탁 드려요."))) + .andExpect(jsonPath("$.data[0].hashTags[0].id", is(1))) + .andExpect(jsonPath("$.data[0].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data.length()", is(2))); } @Test @DisplayName("솔루션을 조회한다.") void getSolution() throws Exception { - SolutionResponse response = SolutionResponse.from(SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build()); + SolutionResponse response = SolutionResponse.from(createSolution()); BDDMockito.given(solutionService.getById(any())) .willReturn(response); @@ -74,19 +72,15 @@ void getSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } @Test @DisplayName("솔루션을 제출한다.") - void createSolution() throws Exception { - Solution solution = SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build(); - SolutionResponse response = SolutionResponse.from(solution); + void submitSolution() throws Exception { + SolutionResponse response = SolutionResponse.from(createSolution()); SubmitSolutionRequest request = new SubmitSolutionRequest( 1L, "value", @@ -110,6 +104,7 @@ void createSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } @@ -117,12 +112,7 @@ void createSolution() throws Exception { @Test @DisplayName("솔루션을 시작한다.") void startSolution() throws Exception { - Solution solution = SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build(); - SolutionResponse response = SolutionResponse.start(solution); + SolutionResponse response = SolutionResponse.start(createSolution()); BDDMockito.given(solutionService.startMission(any(), any())) .willReturn(response); StartSolutionRequest request = new StartSolutionRequest(1L); @@ -144,6 +134,7 @@ void startSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } @@ -168,4 +159,23 @@ void getMySolutions() throws Exception { .andExpect(jsonPath("$.data[1].thumbnail", equalTo("thumbnail"))) .andExpect(jsonPath("$.data[1].title", equalTo("title"))); } + + private Solution createSolution() { + HashTag hashTag = HashTagTestData.defaultHashTag() + .withId(1L) + .build(); + Member member = MemberTestData.defaultMember() + .withId(1L) + .build(); + Mission mission = MissionTestData.defaultMission() + .withId(1L) + .withHashTags(List.of(hashTag)) + .build(); + + return SolutionTestData.defaultSolution() + .withId(1L) + .withMission(mission) + .withMember(member) + .build(); + } } diff --git a/backend/src/test/java/develup/application/mission/MissionServiceTest.java b/backend/src/test/java/develup/application/mission/MissionServiceTest.java index dcdeb6a02..43a14eb15 100644 --- a/backend/src/test/java/develup/application/mission/MissionServiceTest.java +++ b/backend/src/test/java/develup/application/mission/MissionServiceTest.java @@ -7,6 +7,8 @@ import java.util.List; import develup.api.exception.DevelupException; import develup.application.auth.Accessor; +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; @@ -14,6 +16,7 @@ import develup.domain.solution.Solution; import develup.domain.solution.SolutionRepository; import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -35,11 +38,14 @@ class MissionServiceTest extends IntegrationTestSupport { @Autowired private SolutionRepository solutionRepository; + @Autowired + private HashTagRepository hashTagRepository; + @Test @DisplayName("미션 목록을 조회한다.") void getMissions() { - missionRepository.save(MissionTestData.defaultMission().build()); - missionRepository.save(MissionTestData.defaultMission().build()); + createMission(); + createMission(); List responses = missionService.getMissions(); @@ -57,7 +63,8 @@ void getMissionFailWhenInvalidMissionId() { @Test @DisplayName("비로그인 사용자가 미션 조회 시 시작 상태는 false이다.") void getMission_guest() { - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Mission mission = createMission(); + MissionWithStartedResponse response = missionService.getMission(Accessor.GUEST, mission.getId()); assertThat(response.isStarted()).isFalse(); @@ -66,7 +73,7 @@ void getMission_guest() { @Test @DisplayName("미션을 시작하지 않은 로그인 사용자가 미션 조회 시 시작 상태는 false이다.") void getMission_notStarted() { - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Mission mission = createMission(); Member member = memberRepository.save(MemberTestData.defaultMember().build()); Accessor accessor = new Accessor(member.getId()); @@ -79,12 +86,13 @@ void getMission_notStarted() { @DisplayName("미션을 시작한 로그인 사용자가 미션 조회 시 시작 상태는 true이다.") void getMission_started() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); - solutionRepository.save(SolutionTestData.defaultSolution() + Mission mission = createMission(); + Solution solution = SolutionTestData.defaultSolution() .withMember(member) .withMission(mission) .withStatus(IN_PROGRESS) - .build()); + .build(); + solutionRepository.save(solution); Accessor accessor = new Accessor(member.getId()); MissionWithStartedResponse response = missionService.getMission(accessor, mission.getId()); @@ -116,4 +124,13 @@ void getInProgressMissions() { assertThat(inProgressMissions).hasSize(2); } + + private Mission createMission() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + + return missionRepository.save(mission); + } } diff --git a/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java b/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java new file mode 100644 index 000000000..c1239692f --- /dev/null +++ b/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java @@ -0,0 +1,78 @@ +package develup.domain.mission; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Collections; +import java.util.List; +import develup.api.exception.DevelupException; +import develup.domain.hashtag.HashTag; +import develup.support.data.HashTagTestData; +import develup.support.data.MissionTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MissionHashTagsTest { + + @Test + @DisplayName("중복된 해시태그로 생성할 수 없다.") + void cantCreateWithDuplicatedHashTags() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + List duplicatedHashTags = List.of(java, java); + Mission mission = MissionTestData.defaultMission().build(); + + assertThatThrownBy(() -> new MissionHashTags(mission, duplicatedHashTags)) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } + + @Test + @DisplayName("해시 태깅을 할 수 있다.") + void addAll() { + List tags = List.of( + HashTagTestData.defaultHashTag().withName("JAVA").build(), + HashTagTestData.defaultHashTag().withName("JAVASCRIPT").build() + ); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + + missionHashTags.addAll(mission, tags); + + assertThat(missionHashTags.getHashTags()).hasSize(2); + } + + @Test + @DisplayName("중복으로 들어온 해시태그를 등록할 수 없다.") + void duplicatedTag() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + List duplicatedHashTags = List.of(java, java); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + + assertThatThrownBy(() -> missionHashTags.addAll(mission, duplicatedHashTags)) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } + + @Test + @DisplayName("이미 존재하는 태그는 등록할 수 없다.") + void alreadyExistTag() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + missionHashTags.addAll(mission, List.of(java)); + + assertThatThrownBy(() -> missionHashTags.addAll(mission, List.of(java))) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } +} diff --git a/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java new file mode 100644 index 000000000..e5201a7ec --- /dev/null +++ b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java @@ -0,0 +1,63 @@ +package develup.domain.mission; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import develup.domain.hashtag.HashTag; +import develup.domain.hashtag.HashTagRepository; +import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; +import develup.support.data.MissionTestData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class MissionRepositoryTest extends IntegrationTestSupport { + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private HashTagRepository hashTagRepository; + + @Test + @DisplayName("주어진 식별자에 해당하고, 해시태그가 존재하는 미션을 찾는다. ") + void findHashTaggedMissionById() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission hashTaggedMission = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + Mission nonTaggedMission = MissionTestData.defaultMission().build(); + missionRepository.saveAll(List.of(hashTaggedMission, nonTaggedMission)); + + Optional hashTaggedFound = missionRepository.findHashTaggedMissionById(hashTaggedMission.getId()); + Optional noneTaggedFound = missionRepository.findHashTaggedMissionById(nonTaggedMission.getId()); + + Assertions.assertAll( + () -> assertThat(hashTaggedFound) + .isPresent() + .map(it -> it.getHashTags().size()) + .hasValue(1), + () -> assertThat(noneTaggedFound).isEmpty() + ); + } + + @Test + @DisplayName("해시태그가 존재하는 모든 미션을 조회한다.") + void findAllHashTaggedMission() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission1 = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + Mission mission2 = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + missionRepository.saveAll(List.of(mission1, mission2)); + + List missions = missionRepository.findAllHashTaggedMission(); + + assertThat(missions).hasSize(2); + } +} diff --git a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java index a41953a6f..60044a41f 100644 --- a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java +++ b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java @@ -5,11 +5,14 @@ import java.util.List; import java.util.Optional; +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.MissionRepository; import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -28,6 +31,9 @@ class SolutionRepositoryTest extends IntegrationTestSupport { @Autowired private MissionRepository missionRepository; + @Autowired + private HashTagRepository hashTagRepository; + @Test @DisplayName("멤버 식별자와 미션 식별자와 특정 상태에 해당하는 솔루션이 존재하는지 확인한다. ") void exists() { @@ -61,13 +67,13 @@ void exists() { } @Test - @DisplayName("완료된 솔루션 요약 데이터를 조회할 수 있다.") - void findCompletedSummaries() { + @DisplayName("완료된 솔루션을 조회할 수 있다.") + void findAllCompletedSolution() { createSolution(SolutionStatus.COMPLETED); createSolution(SolutionStatus.COMPLETED); createSolution(SolutionStatus.IN_PROGRESS); - List actual = solutionRepository.findCompletedSummaries(); + List actual = solutionRepository.findAllCompletedSolution(); assertThat(actual).hasSize(2); } @@ -144,8 +150,10 @@ void findByMember_IdAndStatus() { } private void createSolution(SolutionStatus status) { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("A").build()); Member member = memberRepository.save(MemberTestData.defaultMember().build()); - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Mission mission = MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build(); + missionRepository.save(mission); Solution solution = SolutionTestData.defaultSolution() .withMember(member) diff --git a/backend/src/test/java/develup/support/data/HashTagTestData.java b/backend/src/test/java/develup/support/data/HashTagTestData.java new file mode 100644 index 000000000..d28b165cd --- /dev/null +++ b/backend/src/test/java/develup/support/data/HashTagTestData.java @@ -0,0 +1,30 @@ +package develup.support.data; + +import develup.domain.hashtag.HashTag; + +public class HashTagTestData { + + public static HashTagBuilder defaultHashTag() { + return new HashTagBuilder().withName("JAVA"); + } + + public static class HashTagBuilder { + + private Long id; + private String name; + + public HashTagBuilder withId(Long id) { + this.id = id; + return this; + } + + public HashTagBuilder withName(String name) { + this.name = name; + return this; + } + + public HashTag build() { + return new HashTag(id, name); + } + } +} diff --git a/backend/src/test/java/develup/support/data/MissionTestData.java b/backend/src/test/java/develup/support/data/MissionTestData.java index 75ae5b82f..1e9e522fa 100644 --- a/backend/src/test/java/develup/support/data/MissionTestData.java +++ b/backend/src/test/java/develup/support/data/MissionTestData.java @@ -1,5 +1,8 @@ package develup.support.data; +import java.util.Collections; +import java.util.List; +import develup.domain.hashtag.HashTag; import develup.domain.mission.Mission; public class MissionTestData { @@ -9,7 +12,8 @@ public static MissionBuilder defaultMission() { .withTitle("루터회관 흡연단속") .withThumbnail("https://thumbnail.com/1.png") .withSummary("담배피다 걸린 행성이를 위한 벌금 계산 미션") - .withUrl("https://github.com/develup-mission/java-smoking"); + .withUrl("https://github.com/develup-mission/java-smoking") + .withHashTags(Collections.emptyList()); } public static class MissionBuilder { @@ -19,6 +23,7 @@ public static class MissionBuilder { private String thumbnail; private String summary; private String url; + private List hashTags; public MissionBuilder withId(Long id) { this.id = id; @@ -45,13 +50,19 @@ public MissionBuilder withUrl(String url) { return this; } + public MissionBuilder withHashTags(List hashTags) { + this.hashTags = hashTags; + return this; + } + public Mission build() { return new Mission( id, title, thumbnail, summary, - url + url, + hashTags ); } }