diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java index dc4398134..4bbbac6aa 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import umc.cockple.demo.domain.exercise.dto.*; @@ -38,11 +39,11 @@ public class ExerciseController { @PostMapping("/parties/{partyId}/exercises") @Operation(summary = "운동 생성", - description = "모임 내에서 새로운 운동을 생성합니다. 모임장만 생성 가능합니다.") + description = "모임 내에서 새로운 운동을 생성합니다. 모임장과 부모임장만 생성 가능합니다.") @ApiResponse(responseCode = "201", description = "운동 생성 성공") @ApiResponse(responseCode = "400", description = "입력값 오류") @ApiResponse(responseCode = "403", description = "권한 없음") - public BaseResponse createExercise( + public ResponseEntity> createExercise( @PathVariable Long partyId, @Valid @RequestBody ExerciseCreateDTO.Request request ) { @@ -51,16 +52,16 @@ public BaseResponse createExercise( ExerciseCreateDTO.Response response = exerciseCommandService.createExercise( partyId, memberId, request); - return BaseResponse.success(CommonSuccessCode.CREATED, response); + return BaseResponse.of(CommonSuccessCode.CREATED, response); } @DeleteMapping("/exercises/{exerciseId}") @Operation(summary = "운동 삭제", - description = "모임장이 운동을 삭제합니다. 삭제된 운동의 모든 참여자와 게스트도 함께 삭제됩니다.") + description = "모임장 또는 부모임장이 운동을 삭제합니다. 삭제된 운동의 모든 참여자와 게스트도 함께 삭제됩니다.") @ApiResponse(responseCode = "200", description = "운동 삭제 성공") @ApiResponse(responseCode = "403", description = "권한 없음 (모임장이 아님)") @ApiResponse(responseCode = "404", description = "운동을 찾을 수 없음") - public BaseResponse deleteExercise( + public ResponseEntity> deleteExercise( @PathVariable Long exerciseId ) { Long memberId = SecurityUtil.getCurrentMemberId(); @@ -68,17 +69,17 @@ public BaseResponse deleteExercise( ExerciseDeleteDTO.Response response = exerciseCommandService.deleteExercise( exerciseId, memberId); - return BaseResponse.success(CommonSuccessCode.OK, response); + return BaseResponse.of(CommonSuccessCode.OK, response); } @PatchMapping("/exercises/{exerciseId}") @Operation(summary = "운동 수정", - description = "모임장이 생성한 운동의 정보를 수정합니다. 이미 시작된 운동은 수정할 수 없습니다.") + description = "모임장 또는 부모임장이 생성한 운동의 정보를 수정합니다. 이미 시작된 운동은 수정할 수 없습니다.") @ApiResponse(responseCode = "200", description = "운동 수정 성공") @ApiResponse(responseCode = "400", description = "입력값 오류 또는 비즈니스 룰 위반") @ApiResponse(responseCode = "403", description = "권한 없음 (모임장이 아님)") @ApiResponse(responseCode = "404", description = "존재하지 않는 운동") - public BaseResponse updateExercise( + public ResponseEntity> updateExercise( @PathVariable Long exerciseId, @Valid @RequestBody ExerciseUpdateDTO.Request request ) { @@ -87,7 +88,7 @@ public BaseResponse updateExercise( ExerciseUpdateDTO.Response response = exerciseCommandService.updateExercise( exerciseId, memberId, request); - return BaseResponse.success(CommonSuccessCode.OK, response); + return BaseResponse.of(CommonSuccessCode.OK, response); } @PostMapping("/exercises/{exerciseId}/participants") @@ -131,7 +132,7 @@ public BaseResponse cancelParticipation( @ApiResponse(responseCode = "400", description = "취소할 수 없는 상태 (이미 시작됨, 참여하지 않음 등)") @ApiResponse(responseCode = "403", description = "권한 없음 (매니저가 아님)") @ApiResponse(responseCode = "404", description = "운동 또는 참여 기록을 찾을 수 없음") - public BaseResponse cancelParticipationByManager( + public ResponseEntity> cancelParticipationByManager( @PathVariable Long exerciseId, @PathVariable Long participantId, @Valid @RequestBody ExerciseCancelDTO.ByManagerRequest request @@ -141,7 +142,7 @@ public BaseResponse cancelParticipationByManager( ExerciseCancelDTO.Response response = exerciseCommandService.cancelParticipationByManager( exerciseId, participantId, memberId, request); - return BaseResponse.success(CommonSuccessCode.OK, response); + return BaseResponse.of(CommonSuccessCode.OK, response); } @PostMapping("/exercises/{exerciseId}/guests") diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java index b2deae21c..a81174b3f 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java @@ -70,11 +70,11 @@ public void validateCancelGuestParticipationByManager(Guest guest, Exercise exer } public void validateDeleteExercise(Exercise exercise, Long memberId) { - validateManagerPermission(memberId, exercise.getParty()); + validateSubManagerPermission(memberId, exercise.getParty()); } public void validateUpdateExercise(Exercise exercise, Member member, ExerciseUpdateDTO.Request request) { - validateManagerPermission(member.getId(), exercise.getParty()); + validateSubManagerPermission(member.getId(), exercise.getParty()); validateAlreadyStarted(exercise, ExerciseErrorCode.EXERCISE_ALREADY_STARTED_UPDATE); validateUpdateTime(request, exercise); } @@ -87,15 +87,6 @@ private void validatePartyIsActive(Party party) { } } - private void validateManagerPermission(Long memberId, Party party) { - boolean isOwner = party.getOwnerId().equals(memberId); - boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), memberId, Role.party_MANAGER); - - if (!isOwner && !isManager) - throw new ExerciseException(ExerciseErrorCode.INSUFFICIENT_PERMISSION); - } - private void validateSubManagerPermission(Long memberId, Party party) { boolean isOwner = party.getOwnerId().equals(memberId); boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index d2444ac25..041d34b9e 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -58,36 +58,46 @@ public class Member extends BaseEntity { @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List contests = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List notifications = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List keywords = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List addresses = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List memberParties = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List memberExercises = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List exerciseBookmarks = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List partyBookmarks = new ArrayList<>(); @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) private ProfileImg profileImg; @OneToMany(mappedBy = "sender") + @Builder.Default private List chatMessages = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List chatRoomMembers = new ArrayList<>(); diff --git a/src/main/java/umc/cockple/demo/global/response/BaseResponse.java b/src/main/java/umc/cockple/demo/global/response/BaseResponse.java index 184089645..37b1ab907 100644 --- a/src/main/java/umc/cockple/demo/global/response/BaseResponse.java +++ b/src/main/java/umc/cockple/demo/global/response/BaseResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Getter; +import org.springframework.http.ResponseEntity; import umc.cockple.demo.global.response.code.BaseCode; import umc.cockple.demo.global.response.code.BaseErrorCode; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; @@ -84,4 +85,23 @@ public static BaseResponse error(BaseErrorCode errorCode, String customMe .build(); } + public static ResponseEntity> of(BaseCode code) { + return of(code, null); + } + + public static ResponseEntity> of(BaseCode code, T data) { + ReasonDTO reason = code.getReason(); + + BaseResponse body = BaseResponse.builder() + .isSuccess(reason.getHttpStatus().is2xxSuccessful()) + .code(reason.getCode()) + .message(reason.getMessage()) + .data(data) + .build(); + + return ResponseEntity + .status(reason.getHttpStatus()) + .body(body); + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java new file mode 100644 index 000000000..da31b7003 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java @@ -0,0 +1,620 @@ +package umc.cockple.demo.domain.exercise.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExerciseIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired MemberRepository memberRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired GuestRepository guestRepository; + + private Member manager; + private Member subManager; + private Member normalMember; + private Party party; + + @BeforeEach + void setUp() { + manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L)); + subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L)); + normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L)); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); + + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + } + + @AfterEach + void tearDown() { + guestRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("POST /api/parties/{partyId}/exercises - 운동 생성") + class CreateExercise { + + private ExerciseCreateDTO.Request validRequest; + + @BeforeEach + void setUp() { + validRequest = ExerciseCreateDTO.Request.builder() + .date("2099-12-31") + .buildingName("테스트 체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5) + .longitude(127.0) + .startTime("10:00") + .endTime("12:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("201 - 모임장이 운동을 생성하면 exerciseId를 반환한다") + void owner_createExercise() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.exerciseId").isNumber()); + } + + @Test + @DisplayName("201 - 부모임장도 운동을 생성할 수 있다") + void subManager_createExercise() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.exerciseId").isNumber()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 파티면 에러를 반환한다") + void partyNotFound() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.PARTY_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.PARTY_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + // SecurityContext에는 존재하지 않는 memberId 세팅 + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 비활성화된 파티면 에러를 반환한다") + void inactiveParty() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + party.delete(); + partyRepository.save(party); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_IS_DELETED.getMessage())); + } + + @Test + @DisplayName("403 - 일반 멤버가 생성 시 에러를 반환한다") + void normalMember_forbidden() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getMessage())); + } + + @Test + @DisplayName("400 - 시작 시간이 종료 시간 이후면 에러를 반환한다") + void invalidExerciseTime() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + ExerciseCreateDTO.Request invalidRequest = ExerciseCreateDTO.Request.builder() + .date("2099-12-31") + .buildingName("체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5).longitude(127.0) + .startTime("12:00").endTime("10:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INVALID_EXERCISE_TIME.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INVALID_EXERCISE_TIME.getMessage())); + } + + @Test + @DisplayName("400 - 과거 시간으로 운동 생성 시 에러를 반환한다") + void pastTime() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + ExerciseCreateDTO.Request pastRequest = ExerciseCreateDTO.Request.builder() + .date("2000-01-01") + .buildingName("체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5).longitude(127.0) + .startTime("10:00").endTime("12:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + + mockMvc.perform(post("/api/parties/{partyId}/exercises", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(pastRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED.getMessage())); + } + } + } + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId} - 운동 삭제") + class DeleteExercise { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 모임장이 운동을 삭제하면 deletedExerciseId를 반환한다") + void owner_deleteExercise() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedExerciseId").value(exercise.getId())); + } + + @Test + @DisplayName("200 - 부모임장도 운동을 삭제할 수 있다") + void subManager_deleteExercise() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedExerciseId").value(exercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(delete("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("403 - 일반 멤버가 삭제 시 에러를 반환한다") + void normalMember_forbidden() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getMessage())); + } + } + } + + @Nested + @DisplayName("PATCH /api/exercises/{exerciseId} - 운동 수정") + class UpdateExercise { + + private Exercise exercise; + private ExerciseUpdateDTO.Request validRequest; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31))); + + validRequest = new ExerciseUpdateDTO.Request( + "2099-12-31", + "수정된 체육관", + "서울특별시 강남구 테헤란로 2", + 37.6, + 127.1, + "11:00", + "13:00", + 12, + "공지사항" + ); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 모임장이 운동을 수정하면 exerciseId를 반환한다") + void owner_updateExercise() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exerciseId").value(exercise.getId())); + } + + @Test + @DisplayName("200 - 부모임장도 운동을 수정할 수 있다") + void subManager_updateExercise() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exerciseId").value(exercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("403 - 일반 멤버가 수정 시 에러를 반환한다") + void normalMember_forbidden() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1))); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", startedExercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_UPDATE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_UPDATE.getMessage())); + } + + @Test + @DisplayName("400 - 시작 시간이 종료 시간 이후면 에러를 반환한다") + void invalidTime() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + ExerciseUpdateDTO.Request invalidTimeRequest = new ExerciseUpdateDTO.Request( + "2099-12-31", null, null, null, null, + "13:00", "11:00", null, null + ); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidTimeRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INVALID_EXERCISE_TIME.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INVALID_EXERCISE_TIME.getMessage())); + } + + @Test + @DisplayName("400 - 과거 날짜로 수정 시 에러를 반환한다") + void pastDate() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + ExerciseUpdateDTO.Request pastDateRequest = new ExerciseUpdateDTO.Request( + "2000-01-01", null, null, null, null, + "10:00", "12:00", null, null + ); + + mockMvc.perform(patch("/api/exercises/{exerciseId}", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(pastDateRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED.getMessage())); + } + } + } + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId}/participants/{participantId} - 특정 참여자 운동 취소") + class CancelParticipationByManager { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 모임장이 멤버 참여를 취소하면 memberName을 반환한다") + void owner_cancelMemberParticipation() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, exercise)); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + exercise.getId(), normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").isString()); + } + + @Test + @DisplayName("200 - 부모임장도 멤버 참여를 취소할 수 있다") + void subManager_cancelMemberParticipation() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, exercise)); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + exercise.getId(), normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").isString()); + } + + @Test + @DisplayName("200 - 모임장이 게스트 참여를 취소할 수 있다") + void owner_cancelGuestParticipation() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(true); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + exercise.getId(), guest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value("게스트")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + 999L, normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void managerNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + exercise.getId(), normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("403 - 일반 멤버가 취소 시 에러를 반환한다") + void normalMember_forbidden() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + exercise.getId(), normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INSUFFICIENT_PERMISSION.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1))); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, startedExercise)); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/{participantId}", + startedExercise.getId(), normalMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getMessage())); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java new file mode 100644 index 000000000..506d55746 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java @@ -0,0 +1,399 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDeleteDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.command.ExerciseCommandService; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseGuestService; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseLifecycleService; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseParticipationService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseCommandService") +class ExerciseCommandServiceTest { + + @InjectMocks + private ExerciseCommandService exerciseCommandService; + + @Mock private ExerciseLifecycleService exerciseLifecycleService; + @Mock private ExerciseParticipationService exerciseParticipationService; + @Mock private ExerciseGuestService exerciseGuestService; + + @Mock private PartyRepository partyRepository; + @Mock private MemberRepository memberRepository; + @Mock private ExerciseRepository exerciseRepository; + @Mock private GuestRepository guestRepository; + + private Member manager; + private Party party; + + @BeforeEach + void setUp() { + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("createExercise") + class CreateExercise { + + private ExerciseCreateDTO.Request request; + + @BeforeEach + void setUp() { + request = ExerciseCreateDTO.Request.builder() + .date("2099-12-31") + .buildingName("테스트 체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5) + .longitude(127.0) + .startTime("10:00") + .endTime("12:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Party, Member 조회 후 ExerciseLifecycleService에 위임한다") + void delegatesToLifecycleService() { + // given + ExerciseCreateDTO.Response expectedResponse = ExerciseCreateDTO.Response.builder() + .exerciseId(100L) + .build(); + + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseLifecycleService.createExercise(party, manager, request)).willReturn(expectedResponse); + + // when + ExerciseCreateDTO.Response response = exerciseCommandService.createExercise( + party.getId(), manager.getId(), request); + + // then + assertThat(response.exerciseId()).isEqualTo(100L); + then(exerciseLifecycleService).should().createExercise(party, manager, request); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 파티면 ExerciseException(PARTY_NOT_FOUND)을 던진다") + void partyNotFound_throwsException() { + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.createExercise(999L, manager.getId(), request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.createExercise(party.getId(), 999L, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("deleteExercise") + class DeleteExercise { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Member 조회 후 ExerciseLifecycleService에 위임한다") + void delegatesToLifecycleService() { + // given + ExerciseDeleteDTO.Response expectedResponse = ExerciseDeleteDTO.Response.builder() + .deletedExerciseId(100L) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseLifecycleService.deleteExercise(exercise, manager)).willReturn(expectedResponse); + + // when + ExerciseDeleteDTO.Response response = exerciseCommandService.deleteExercise( + exercise.getId(), manager.getId()); + + // then + assertThat(response.deletedExerciseId()).isEqualTo(100L); + then(exerciseLifecycleService).should().deleteExercise(exercise, manager); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.deleteExercise(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.deleteExercise(exercise.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("updateExercise") + class UpdateExercise { + + private Exercise exercise; + private ExerciseUpdateDTO.Request request; + + @BeforeEach + void setUp() { + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + + request = new ExerciseUpdateDTO.Request( + "2099-12-31", + "수정된 체육관", + "서울특별시 강남구 테헤란로 2", + 37.6, + 127.1, + "11:00", + "13:00", + 12, + "공지사항" + ); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Member 조회 후 ExerciseLifecycleService에 위임한다") + void delegatesToLifecycleService() { + // given + ExerciseUpdateDTO.Response expectedResponse = ExerciseUpdateDTO.Response.builder() + .exerciseId(100L) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseLifecycleService.updateExercise(exercise, manager, request)).willReturn(expectedResponse); + + // when + ExerciseUpdateDTO.Response response = exerciseCommandService.updateExercise( + exercise.getId(), manager.getId(), request); + + // then + assertThat(response.exerciseId()).isEqualTo(100L); + then(exerciseLifecycleService).should().updateExercise(exercise, manager, request); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.updateExercise(999L, manager.getId(), request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.updateExercise(exercise.getId(), 999L, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("cancelParticipationByManager") + class CancelParticipationByManager { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Manager 조회 후 ExerciseParticipationService에 위임한다") + void delegatesToParticipationService() { + // given + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + ExerciseCancelDTO.Response expectedResponse = ExerciseCancelDTO.Response.builder() + .memberName("참여자") + .currentParticipants(0) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseParticipationService.cancelParticipationByManager(exercise, 2L, manager, request)) + .willReturn(expectedResponse); + + // when + ExerciseCancelDTO.Response response = exerciseCommandService + .cancelParticipationByManager(exercise.getId(), 2L, manager.getId(), request); + + // then + assertThat(response.memberName()).isEqualTo("참여자"); + then(exerciseParticipationService).should() + .cancelParticipationByManager(exercise, 2L, manager, request); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelParticipationByManager(999L, 2L, manager.getId(), request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 매니저면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void managerNotFound_throwsException() { + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelParticipationByManager(exercise.getId(), 2L, 999L, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java new file mode 100644 index 000000000..5a98b3a02 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java @@ -0,0 +1,486 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseDeleteDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseLifecycleService; +import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseLifecycleService") +class ExerciseLifecycleServiceTest { + + // 인프라 의존성만 Mock + @Mock private ExerciseRepository exerciseRepository; + @Mock private PartyRepository partyRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private ImageService imageService; + + private ExerciseLifecycleService exerciseLifecycleService; + + private Member manager; + private Party party; + + @BeforeEach + void setUp() { + ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository); + ExerciseConverter exerciseConverter = new ExerciseConverter(imageService); + exerciseLifecycleService = new ExerciseLifecycleService( + exerciseRepository, partyRepository, exerciseValidator, exerciseConverter); + + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("createExercise") + class CreateExercise { + + private ExerciseCreateDTO.Request validRequest; + + @BeforeEach + void setUp() { + validRequest = ExerciseCreateDTO.Request.builder() + .date("2099-12-31") + .buildingName("테스트 체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5) + .longitude(127.0) + .startTime("10:00") + .endTime("12:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임장이 정상 요청하면 운동이 저장되고 Response를 반환한다") + void ownerCreatesExercise_success() { + // given: 모임장(ownerId 일치)이므로 권한 통과, 매니저 role stub은 불필요 + Exercise savedExercise = Exercise.builder() + .date(validRequest.toParsedDate()) + .startTime(validRequest.toParsedStartTime()) + .endTime(validRequest.toParsedEndTime()) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(savedExercise, "id", 100L); + + given(exerciseRepository.save(any(Exercise.class))).willReturn(savedExercise); + + // when + ExerciseCreateDTO.Response response = exerciseLifecycleService.createExercise(party, manager, validRequest); + + // then + assertThat(response.exerciseId()).isEqualTo(100L); + then(exerciseRepository).should().save(any(Exercise.class)); + } + + @Test + @DisplayName("부모임장도 운동을 생성할 수 있다") + void subManagerCreatesExercise_success() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(subManager, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + .willReturn(true); + + Exercise savedExercise = Exercise.builder() + .date(validRequest.toParsedDate()) + .startTime(validRequest.toParsedStartTime()) + .endTime(validRequest.toParsedEndTime()) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(savedExercise, "id", 200L); + + given(exerciseRepository.save(any(Exercise.class))).willReturn(savedExercise); + + // when + ExerciseCreateDTO.Response response = exerciseLifecycleService.createExercise(party, subManager, validRequest); + + // then + assertThat(response.exerciseId()).isEqualTo(200L); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("비활성화된 파티면 PartyException(PARTY_IS_DELETED)을 던진다") + void inactiveParty_throwsException() { + party.delete(); // 실제 도메인 메서드 호출 + + assertThatThrownBy(() -> + exerciseLifecycleService.createExercise(party, manager, validRequest)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + + @Test + @DisplayName("일반 멤버가 생성 시 ExerciseException(INSUFFICIENT_PERMISSION)을 던진다") + void normalMember_throwsException() { + Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(normalMember, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + .willReturn(false); + + assertThatThrownBy(() -> + exerciseLifecycleService.createExercise(party, normalMember, validRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INSUFFICIENT_PERMISSION)); + } + + @Test + @DisplayName("시작 시간이 종료 시간 이후면 ExerciseException(INVALID_EXERCISE_TIME)을 던진다") + void invalidExerciseTime_throwsException() { + // given: 모임장 권한 통과 후 시간 검증에서 실패 + ExerciseCreateDTO.Request invalidTimeRequest = ExerciseCreateDTO.Request.builder() + .date("2099-12-31") + .buildingName("체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5).longitude(127.0) + .startTime("12:00").endTime("10:00") // 종료가 시작보다 빠름 + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + + assertThatThrownBy(() -> + exerciseLifecycleService.createExercise(party, manager, invalidTimeRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INVALID_EXERCISE_TIME)); + } + + @Test + @DisplayName("과거 시간이면 ExerciseException(PAST_TIME_NOT_ALLOWED)을 던진다") + void pastTime_throwsException() { + // given: 모임장 권한 통과 후 시간 검증에서 실패 + ExerciseCreateDTO.Request pastRequest = ExerciseCreateDTO.Request.builder() + .date("2000-01-01") + .buildingName("체육관") + .roadAddress("서울특별시 강남구 테헤란로 1") + .latitude(37.5).longitude(127.0) + .startTime("10:00").endTime("12:00") + .maxCapacity(10) + .allowMemberGuestsInvitation(true) + .allowExternalGuests(false) + .build(); + + assertThatThrownBy(() -> + exerciseLifecycleService.createExercise(party, manager, pastRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED)); + } + } + } + + @Nested + @DisplayName("deleteExercise") + class DeleteExercise { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + exercise.setParty(party); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임장이 운동을 삭제하면 deletedExerciseId를 반환한다") + void ownerDeletesExercise_success() { + // given: party.ownerId == manager.id 이므로 권한 통과 + + // when + ExerciseDeleteDTO.Response response = exerciseLifecycleService.deleteExercise(exercise, manager); + + // then + assertThat(response.deletedExerciseId()).isEqualTo(100L); + then(exerciseRepository).should().delete(exercise); + then(partyRepository).should().save(party); + } + + @Test + @DisplayName("부모임장도 운동을 삭제할 수 있다") + void subManagerDeletesExercise_success() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(subManager, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + .willReturn(true); + + // when + ExerciseDeleteDTO.Response response = exerciseLifecycleService.deleteExercise(exercise, subManager); + + // then + assertThat(response.deletedExerciseId()).isEqualTo(100L); + then(exerciseRepository).should().delete(exercise); + then(partyRepository).should().save(party); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("일반 멤버가 삭제 시 ExerciseException(INSUFFICIENT_PERMISSION)을 던진다") + void normalMember_throwsException() { + Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(normalMember, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + .willReturn(false); + + assertThatThrownBy(() -> + exerciseLifecycleService.deleteExercise(exercise, normalMember)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INSUFFICIENT_PERMISSION)); + } + } + } + + @Nested + @DisplayName("updateExercise") + class UpdateExercise { + + private Exercise exercise; + private ExerciseUpdateDTO.Request validRequest; + + @BeforeEach + void setUp() { + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + exercise.setParty(party); + + validRequest = new ExerciseUpdateDTO.Request( + "2099-12-31", + "수정된 체육관", + "서울특별시 강남구 테헤란로 2", + 37.6, + 127.1, + "11:00", + "13:00", + 12, + "공지사항" + ); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임장이 운동을 수정하면 Response를 반환한다") + void ownerUpdatesExercise_success() { + // given: party.ownerId == manager.id 이므로 권한 통과 + Exercise savedExercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(11, 0)) + .endTime(LocalTime.of(13, 0)) + .maxCapacity(12) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(savedExercise, "id", 100L); + given(exerciseRepository.save(any(Exercise.class))).willReturn(savedExercise); + + // when + ExerciseUpdateDTO.Response response = exerciseLifecycleService.updateExercise(exercise, manager, validRequest); + + // then + assertThat(response.exerciseId()).isEqualTo(100L); + then(exerciseRepository).should().save(exercise); + } + + @Test + @DisplayName("부모임장도 운동을 수정할 수 있다") + void subManagerUpdatesExercise_success() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(subManager, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + .willReturn(true); + + Exercise savedExercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(11, 0)) + .endTime(LocalTime.of(13, 0)) + .maxCapacity(12) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(savedExercise, "id", 100L); + given(exerciseRepository.save(any(Exercise.class))).willReturn(savedExercise); + + // when + ExerciseUpdateDTO.Response response = exerciseLifecycleService.updateExercise(exercise, subManager, validRequest); + + // then + assertThat(response.exerciseId()).isEqualTo(100L); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("일반 멤버가 수정 시 ExerciseException(INSUFFICIENT_PERMISSION)을 던진다") + void normalMember_throwsException() { + Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(normalMember, "id", 2L); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + .willReturn(false); + + assertThatThrownBy(() -> + exerciseLifecycleService.updateExercise(exercise, normalMember, validRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INSUFFICIENT_PERMISSION)); + } + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_UPDATE)을 던진다") + void alreadyStarted_throwsException() { + // given: 과거 날짜로 설정된 운동 (이미 시작됨) + Exercise startedExercise = Exercise.builder() + .date(LocalDate.of(2000, 1, 1)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + startedExercise.setParty(party); + + assertThatThrownBy(() -> + exerciseLifecycleService.updateExercise(startedExercise, manager, validRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_UPDATE)); + } + + @Test + @DisplayName("시작 시간이 종료 시간 이후면 ExerciseException(INVALID_EXERCISE_TIME)을 던진다") + void invalidTime_throwsException() { + ExerciseUpdateDTO.Request invalidTimeRequest = new ExerciseUpdateDTO.Request( + "2099-12-31", null, null, null, null, + "13:00", "11:00", null, null + ); + + assertThatThrownBy(() -> + exerciseLifecycleService.updateExercise(exercise, manager, invalidTimeRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INVALID_EXERCISE_TIME)); + } + + @Test + @DisplayName("과거 날짜로 수정 시 ExerciseException(PAST_TIME_NOT_ALLOWED)을 던진다") + void pastDate_throwsException() { + ExerciseUpdateDTO.Request pastDateRequest = new ExerciseUpdateDTO.Request( + "2000-01-01", null, null, null, null, + "10:00", "12:00", null, null + ); + + assertThatThrownBy(() -> + exerciseLifecycleService.updateExercise(exercise, manager, pastDateRequest)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.PAST_TIME_NOT_ALLOWED)); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java new file mode 100644 index 000000000..d3af78261 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java @@ -0,0 +1,251 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseParticipationService; +import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseParticipationService") +class ExerciseParticipationServiceTest { + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private ImageService imageService; + + private ExerciseParticipationService exerciseParticipationService; + + private Member manager; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository); + ExerciseConverter exerciseConverter = new ExerciseConverter(imageService); + exerciseParticipationService = new ExerciseParticipationService( + exerciseRepository, memberRepository, memberPartyRepository, + memberExerciseRepository, guestRepository, exerciseValidator, exerciseConverter); + + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = Exercise.builder() + .date(LocalDate.of(2099, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(exercise, "id", 100L); + exercise.setParty(party); + } + + @Nested + @DisplayName("cancelParticipationByManager") + class CancelParticipationByManager { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임장이 일반 멤버 참여를 취소하면 Response를 반환한다") + void ownerCancelsMemberParticipation_success() { + // given + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, exercise); + ReflectionTestUtils.setField(memberExercise, "id", 50L); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(memberRepository.findById(participant.getId())).willReturn(Optional.of(participant)); + given(memberExerciseRepository.findByExerciseAndMember(exercise, participant)) + .willReturn(Optional.of(memberExercise)); + + // when + ExerciseCancelDTO.Response response = exerciseParticipationService + .cancelParticipationByManager(exercise, participant.getId(), manager, request); + + // then + assertThat(response.memberName()).isEqualTo(participant.getMemberName()); + then(memberExerciseRepository).should().delete(memberExercise); + then(exerciseRepository).should().save(exercise); + } + + @Test + @DisplayName("부모임장도 일반 멤버 참여를 취소할 수 있다") + void subManagerCancelsMemberParticipation_success() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(subManager, "id", 2L); + + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 3L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, exercise); + ReflectionTestUtils.setField(memberExercise, "id", 50L); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + .willReturn(true); + given(memberRepository.findById(participant.getId())).willReturn(Optional.of(participant)); + given(memberExerciseRepository.findByExerciseAndMember(exercise, participant)) + .willReturn(Optional.of(memberExercise)); + + // when + ExerciseCancelDTO.Response response = exerciseParticipationService + .cancelParticipationByManager(exercise, participant.getId(), subManager, request); + + // then + assertThat(response.memberName()).isEqualTo(participant.getMemberName()); + then(memberExerciseRepository).should().delete(memberExercise); + } + + @Test + @DisplayName("모임장이 게스트 참여를 취소하면 Response를 반환한다") + void ownerCancelsGuestParticipation_success() { + // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 60L); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(true); + + given(guestRepository.findById(guest.getId())).willReturn(Optional.of(guest)); + + // when + ExerciseCancelDTO.Response response = exerciseParticipationService + .cancelParticipationByManager(exercise, guest.getId(), manager, request); + + // then + assertThat(response.memberName()).isEqualTo("게스트"); + then(guestRepository).should().delete(guest); + then(exerciseRepository).should().save(exercise); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("일반 멤버가 취소 시 ExerciseException(INSUFFICIENT_PERMISSION)을 던진다") + void normalMember_throwsException() { + Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); + ReflectionTestUtils.setField(normalMember, "id", 2L); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + .willReturn(false); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + .willReturn(false); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipationByManager(exercise, 2L, normalMember, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.INSUFFICIENT_PERMISSION)); + } + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다") + void alreadyStarted_throwsException() { + Exercise startedExercise = Exercise.builder() + .date(LocalDate.of(2000, 1, 1)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .build(); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + startedExercise.setParty(party); + + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipationByManager(startedExercise, 2L, manager, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL)); + } + + @Test + @DisplayName("존재하지 않는 멤버 참여자면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberParticipantNotFound_throwsException() { + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); + + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipationByManager(exercise, 999L, manager, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 게스트면 ExerciseException(GUEST_NOT_FOUND)을 던진다") + void guestNotFound_throwsException() { + ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(true); + + given(guestRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipationByManager(exercise, 999L, manager, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.GUEST_NOT_FOUND)); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java new file mode 100644 index 000000000..1fd4044d2 --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java @@ -0,0 +1,20 @@ +package umc.cockple.demo.support.fixture; + +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; + +public class GuestFixture { + + public static Guest createGuest(Exercise exercise, Long inviterId) { + Guest guest = Guest.builder() + .guestName("게스트") + .gender(Gender.MALE) + .level(Level.B) + .inviterId(inviterId) + .build(); + guest.setExercise(exercise); + return guest; + } +} \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java index c14c1cf2c..19af741dc 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java @@ -18,6 +18,7 @@ public class MemberFixture { public static Member createMember(String nickname, Gender gender, Level level, Long socialId) { return Member.builder() + .memberName(nickname) .nickname(nickname) .gender(gender) .level(level)