diff --git a/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java b/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java index d26eddd..ad4d462 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java @@ -69,13 +69,6 @@ public void updateMissionOrders(Long stampId, UpdateMissionOrderRequest request) } } - public void updateCompleted(Mission mission) { - MissionPolicy.validateNotDeleted(mission); - MissionPolicy.validateCompleted(mission); - - mission.updateCompleted(); - } - @Transactional public void deleteMission(Long stampId, Mission mission) { validateMissionIsActiveAndBelongsToStamp(stampId, mission); @@ -120,10 +113,26 @@ public List getValidMissionsWithStamp(List missionIds) { return missions; } + @Transactional + public void completeMission(Mission mission) { + MissionPolicy.validateNotDeleted(mission); + MissionPolicy.validateCompleted(mission); + + mission.updateCompleted(); + } + public void validateMissionBelongsToStamp(Long stampId, Mission mission) { MissionPolicy.validateMissionBelongsToStamp(stampId, mission); } + @Transactional(readOnly = true) + public void validateAllMissionsCompletedByStampId(Long stampId) { + boolean exists = + missionQueryRepository.existsByStampIdAndCompletedIsFalseAndDeletedAtIsNull( + stampId); + MissionPolicy.validateAllCompleted(exists); + } + private void validateMissionIsActiveAndBelongsToStamp(Long stampId, Mission mission) { MissionPolicy.validateMissionBelongsToStamp(stampId, mission); MissionPolicy.validateNotDeleted(mission); diff --git a/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java b/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java index 64abde0..8e13648 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java +++ b/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java @@ -13,6 +13,7 @@ public enum MissionErrorCode implements ErrorCode { MISSION_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 미션은 이미 삭제되었습니다."), MISSION_ORDER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 미션 순서입니다."), MISSION_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 미션입니다."), + ALL_MISSIONS_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "모든 미션이 완료되지 않았습니다."), // 403 MISSION_NOT_BELONGS_TO_STAMP(HttpStatus.FORBIDDEN, "해당 미션은 요청한 스탬프에 속하지 않습니다."), diff --git a/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java b/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java index 790f2fd..8509f75 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java +++ b/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java @@ -65,4 +65,10 @@ public static void validateExistAll(List foundMissions, List requ throw new CustomException(MissionErrorCode.MISSION_NOT_FOUND); } } + + public static void validateAllCompleted(boolean exists) { + if (exists) { + throw new CustomException(MissionErrorCode.ALL_MISSIONS_NOT_COMPLETED); + } + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java index a9b003a..045dbc8 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java @@ -5,4 +5,6 @@ public interface MissionQueryRepository { List findAllByIdsInFetchJoinStamp(List ids); + + boolean existsByStampIdAndCompletedIsFalseAndDeletedAtIsNull(Long stampId); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java index e91ceb5..3dc4bc5 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java @@ -25,4 +25,46 @@ public List findAllByIdsInFetchJoinStamp(List ids) { .where(mission.id.in(ids)) .fetch(); } + + @Override + public boolean existsByStampIdAndCompletedIsFalseAndDeletedAtIsNull(Long stampId) { + Integer hit = + queryFactory + .selectOne() + .from(mission) + .where( + mission.stamp.id.eq(stampId), + mission.completed.isFalse(), + mission.deletedAt.isNull()) + .fetchFirst(); + + return hit != null; + } + + // @Override + // public long countByStampIdAndDeletedAtIsNull(Long stampId) { + // Long count = + // queryFactory + // .select(mission.count()) + // .from(mission) + // .where(mission.stamp.id.eq(stampId), mission.deletedAt.isNull()) + // .fetchOne(); + // + // return Optional.ofNullable(count).orElse(0L); + // } + // + // @Override + // public long countByStampIdAndCompletedIsTrueAndDeletedAtIsNull(Long stampId) { + // Long count = + // queryFactory + // .select(mission.count()) + // .from(mission) + // .where( + // mission.stamp.id.eq(stampId), + // mission.completed.isTrue(), + // mission.deletedAt.isNull()) + // .fetchOne(); + // + // return Optional.ofNullable(count).orElse(0L); + // } } diff --git a/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java b/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java index 13b4b0a..869aa95 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java +++ b/src/main/java/com/ject/studytrip/stamp/application/facade/StampFacade.java @@ -73,4 +73,14 @@ public StampDetail getStamp(Long memberId, Long tripId, Long stampId) { return StampDetail.from(StampInfo.from(stamp), missionInfos); } + + public void completeStamp(Long memberId, Long tripId, Long stampId) { + Trip trip = tripService.getValidTrip(memberId, tripId); + Stamp stamp = stampService.getValidStamp(trip.getId(), stampId); + + missionService.validateAllMissionsCompletedByStampId(stamp.getId()); + + stampService.completeStamp(stamp); + tripService.increaseCompletedStamps(trip); + } } diff --git a/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java b/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java index 077615e..45d3662 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java +++ b/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java @@ -17,6 +17,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -133,6 +134,24 @@ public String getStampNameByTripCategory(TripCategory tripCategory, List return getExplorationStampName(stamps); } + @Transactional + public void completeStamp(Stamp stamp) { + StampPolicy.validateCompleted(stamp); + + stamp.updateCompleted(); + } + + public void validateStampBelongsToTrip(Long tripId, Stamp stamp) { + StampPolicy.validateStampBelongsToTrip(tripId, stamp); + } + + @Transactional(readOnly = true) + public void validateAllStampsCompletedByTripId(Long tripId) { + boolean exists = + stampQueryRepository.existsByTripIdAndCompletedIsFalseAndDeletedAtIsNull(tripId); + StampPolicy.validateAllCompleted(exists); + } + private String getCourseStampName(List stamps) { return new HashSet<>(stamps).iterator().next().getName(); } @@ -159,10 +178,6 @@ private String getExplorationStampName(List stamps) { .orElse(""); } - public void validateStampBelongsToTrip(Long tripId, Stamp stamp) { - StampPolicy.validateStampBelongsToTrip(tripId, stamp); - } - private void shiftStampOrdersAfterDeleted(Long tripId, int deletedStampOrder) { List affectedStamps = stampQueryRepository.findStampsToShiftAfterOrder(tripId, deletedStampOrder); diff --git a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java index 716512f..563c6ac 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java @@ -16,6 +16,8 @@ public enum StampErrorCode implements ErrorCode { CANNOT_UPDATE_ORDER_FOR_EXPLORATION_TRIP(HttpStatus.BAD_REQUEST, "탐험형 여행의 스탬프 순서는 변경할 수 없습니다."), INVALID_STAMP_ID_IN_REQUEST(HttpStatus.BAD_REQUEST, "존재하지 않는 스탬프 ID가 포함되어 있습니다. "), STAMP_LIST_CANNOT_BE_EMPTY(HttpStatus.BAD_REQUEST, "스탬프 목록은 비어있을 수 없습니다."), + STAMP_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 스탬프입니다."), + ALL_STAMPS_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "모든 스탬프가 완료되지 않았습니다."), // 403 STAMP_NOT_BELONG_TO_TRIP(HttpStatus.FORBIDDEN, "해당 스탬프는 요청한 여행에 속하지 않습니다."), diff --git a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java index 672fcfb..fed6111 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java @@ -60,4 +60,16 @@ public static void validateStampListNotEmpty(List stamps) { throw new CustomException(StampErrorCode.STAMP_LIST_CANNOT_BE_EMPTY); } } + + public static void validateCompleted(Stamp stamp) { + if (stamp.isCompleted()) { + throw new CustomException(StampErrorCode.STAMP_ALREADY_COMPLETED); + } + } + + public static void validateAllCompleted(boolean exists) { + if (exists) { + throw new CustomException(StampErrorCode.ALL_STAMPS_NOT_COMPLETED); + } + } } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java index 0d59369..8b505d4 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java @@ -8,4 +8,6 @@ public interface StampQueryRepository { List findStampsToShiftAfterOrder(Long tripId, int deletedOrder); Optional findFirstIncompleteStampByTripId(Long tripId); + + boolean existsByTripIdAndCompletedIsFalseAndDeletedAtIsNull(Long tripId); } diff --git a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java similarity index 74% rename from src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java rename to src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java index 7fd7518..9db4d20 100644 --- a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/stamp/infra/querydsl/StampQueryRepositoryAdapter.java @@ -1,4 +1,4 @@ -package com.ject.studytrip.stamp.infra.jpa; +package com.ject.studytrip.stamp.infra.querydsl; import com.ject.studytrip.stamp.domain.model.QStamp; import com.ject.studytrip.stamp.domain.model.Stamp; @@ -40,4 +40,19 @@ public Optional findFirstIncompleteStampByTripId(Long tripId) { .orderBy(stamp.stampOrder.asc()) .fetchFirst()); } + + @Override + public boolean existsByTripIdAndCompletedIsFalseAndDeletedAtIsNull(Long tripId) { + Integer hit = + queryFactory + .selectOne() + .from(stamp) + .where( + stamp.trip.id.eq(tripId), + stamp.completed.isFalse(), + stamp.deletedAt.isNull()) + .fetchOne(); + + return hit != null; + } } diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java b/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java index 419524c..0a069a2 100644 --- a/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java +++ b/src/main/java/com/ject/studytrip/stamp/presentation/controller/StampController.java @@ -24,13 +24,14 @@ @Tag(name = "Stamp", description = "스탬프 API") @RestController +@RequestMapping("/api/trips") @RequiredArgsConstructor @Validated public class StampController { private final StampFacade stampFacade; @Operation(summary = "스탬프 등록", description = "특정 여행에 새로운 스탬프를 등록합니다.") - @PostMapping("/api/trips/{tripId}/stamps") + @PostMapping("/{tripId}/stamps") public ResponseEntity createStamp( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @@ -44,7 +45,7 @@ public ResponseEntity createStamp( } @Operation(summary = "스탬프 수정", description = "특정 스탬프의 이름을 수정합니다.") - @PatchMapping("/api/trips/{tripId}/stamps/{stampId}") + @PatchMapping("/{tripId}/stamps/{stampId}") public ResponseEntity updateStamp( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @@ -57,7 +58,7 @@ public ResponseEntity updateStamp( } @Operation(summary = "스탬프 순서 변경", description = "스탬프 순서를 변경합니다. 스탬프 ID 목록을 최종 순서대로 요청합니다.") - @PutMapping("/api/trips/{tripId}/stamps/orders") + @PutMapping("/{tripId}/stamps/orders") public ResponseEntity updateStampOrders( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @@ -69,7 +70,7 @@ public ResponseEntity updateStampOrders( } @Operation(summary = "스탬프 삭제", description = "특정 스탬프를 삭제합니다.") - @DeleteMapping("/api/trips/{tripId}/stamps/{stampId}") + @DeleteMapping("/{tripId}/stamps/{stampId}") public ResponseEntity deleteStamp( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @@ -81,7 +82,7 @@ public ResponseEntity deleteStamp( } @Operation(summary = "스탬프 목록 조회", description = "특정 여행의 스탬프 목록을 조회합니다.") - @GetMapping("/api/trips/{tripId}/stamps") + @GetMapping("/{tripId}/stamps") public ResponseEntity loadStampsByTrip( @AuthenticationPrincipal String memberId, @PathVariable Long tripId) { List result = stampFacade.getStampsByTrip(Long.valueOf(memberId), tripId); @@ -93,7 +94,7 @@ public ResponseEntity loadStampsByTrip( } @Operation(summary = "스탬프 상세 조회", description = "특정 여행의 특정 스탬프 상세 정보를 조회합니다.") - @GetMapping("api/trips/{tripId}/stamps/{stampId}") + @GetMapping("/{tripId}/stamps/{stampId}") public ResponseEntity loadStamp( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @@ -107,4 +108,16 @@ public ResponseEntity loadStamp( LoadStampDetailResponse.of( result.stampInfo(), result.missionInfos()))); } + + @Operation(summary = "스탬프 완료", description = "특정 스탬프 하위의 모든 미션이 완료된 경우에만 스탬프를 완료합니다.") + @PatchMapping("/{tripId}/stamps/{stampId}/complete") + public ResponseEntity completeStamp( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId) { + stampFacade.completeStamp(Long.valueOf(memberId), tripId, stampId); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java index e7e5899..1309fc6 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java +++ b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java @@ -102,7 +102,7 @@ private void createStudyLogDailyMissionsAndCompleteMissions( // 미션 완료 처리 selectedDailyMissions.forEach( - dailyMission -> missionService.updateCompleted(dailyMission.getMission())); + dailyMission -> missionService.completeMission(dailyMission.getMission())); } @Transactional(readOnly = true) diff --git a/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java b/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java index 1849ec5..aef106e 100644 --- a/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java +++ b/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java @@ -101,6 +101,15 @@ public TripDetail getTrip(Long memberId, Long tripId) { return TripDetail.from(TripInfo.from(trip, dDay, progress), stampInfos); } + public void completeTrip(Long memberId, Long tripId) { + Member member = memberService.getMember(memberId); + Trip trip = tripService.getValidTrip(member.getId(), tripId); + + stampService.validateAllStampsCompletedByTripId(trip.getId()); + + tripService.completeTrip(trip); + } + private Integer calculateDDay(LocalDate endDate) { if (endDate == null) return null; // NULL 인 경우 무기한 여행 diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java index 2196cc1..efd1a2c 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java @@ -98,4 +98,16 @@ public TripCount getActiveTripCountsByMemberId(Long memberId) { memberId, TripCategory.EXPLORE); return TripCount.of(courseCount, exploreCount); } + + @Transactional + public void completeTrip(Trip trip) { + TripPolicy.validateCompleted(trip); + + trip.updateCompleted(); + } + + @Transactional + public void increaseCompletedStamps(Trip trip) { + trip.increaseCompletedStamps(); + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java index db877c2..674bc44 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java +++ b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java @@ -12,6 +12,7 @@ public enum TripErrorCode implements ErrorCode { TRIP_STAMP_REQUIRED(HttpStatus.BAD_REQUEST, "여행을 생성하려면 최소 1개의 스탬프가 필요합니다."), TRIP_END_DATE_BEFORE_START_DATE(HttpStatus.BAD_REQUEST, "여행 종료일은 시작일보다 이후여야 합니다."), COURSE_TRIP_END_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "코스형 여행은 종료일이 필수입니다."), + TRIP_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 여행입니다."), TRIP_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 여행입니다."), // 403 diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java index 7bc704d..d753761 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java @@ -94,11 +94,15 @@ public void decreaseTotalStamps() { this.totalStamps -= 1; } - public void updateIsComplete(boolean completed) { - this.completed = completed; + public void updateCompleted() { + this.completed = true; } public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public void increaseCompletedStamps() { + this.completedStamps += 1; + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java index 3b09f87..22a22cc 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java +++ b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java @@ -37,4 +37,10 @@ public static void validateNotDeleted(Trip trip) { if (trip.getDeletedAt() != null) throw new CustomException(TripErrorCode.TRIP_ALREADY_DELETED); } + + public static void validateCompleted(Trip trip) { + if (trip.isCompleted()) { + throw new CustomException(TripErrorCode.TRIP_ALREADY_COMPLETED); + } + } } diff --git a/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java index 0a4d4cf..9d32682 100644 --- a/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java +++ b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java @@ -29,7 +29,6 @@ @RequiredArgsConstructor @Validated public class TripController { - private final TripFacade tripFacade; @Operation(summary = "여행 카테고리 목록 조회", description = "여행 카테고리 목록을 조회하는 API 입니다.") @@ -112,4 +111,15 @@ public ResponseEntity loadTripDetail( HttpStatus.OK.value(), LoadTripDetailResponse.of(result.tripInfo(), result.stampInfos()))); } + + @Operation(summary = "여행 완료", description = "특정 여행 하위의 모든 스탬프가 완료된 경우에만 여행을 완료합니다.") + @PatchMapping("/{tripId}/complete") + public ResponseEntity completeTrip( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId) { + tripFacade.completeTrip(Long.valueOf(memberId), tripId); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java index e71801d..77e330e 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -329,50 +330,6 @@ void shouldUpdateMissionOrders() { } } - @Nested - @DisplayName("updateCompleted 메서드는") - class updateCompleted { - - @Test - @DisplayName("정상적인 미션이면 completed 필드를 true로 업데이트하고 완료 처리한다") - void shouldUpdateCompletedMission() { - // given - Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); - - // when - missionService.updateCompleted(mission); - - // then - assertThat(mission.isCompleted()).isTrue(); - } - - @Test - @DisplayName("삭제된 미션이면 예외가 발생한다") - void shouldThrowExceptionWhenMissionIsDeleted() { - // given - Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); - ReflectionTestUtils.setField(mission, "deletedAt", LocalDateTime.now()); - - // then - assertThatThrownBy(() -> missionService.updateCompleted(mission)) - .isInstanceOf(CustomException.class) - .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); - } - - @Test - @DisplayName("이미 완료된 미션이면 예외가 발생한다") - void shouldThrowExceptionWhenMissionIsAlreadyCompleted() { - // given - Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); - ReflectionTestUtils.setField(mission, "completed", true); - - // then - assertThatThrownBy(() -> missionService.updateCompleted(mission)) - .isInstanceOf(CustomException.class) - .hasMessage(MissionErrorCode.MISSION_ALREADY_COMPLETED.getMessage()); - } - } - @Nested @DisplayName("deleteMission 메서드는") class DeleteMission { @@ -561,4 +518,78 @@ void shouldThrowExceptionWhenAnyMissionIsCompleted() { .hasMessage(MissionErrorCode.MISSION_ALREADY_COMPLETED.getMessage()); } } + + @Nested + @DisplayName("completeMission 메서드는") + class CompleteMission { + + @Test + @DisplayName("정상적인 미션이면 completed 필드를 true로 업데이트한다") + void shouldCompletedMission() { + // when + missionService.completeMission(exploreMission1); + + // then + assertThat(exploreMission1.isCompleted()).isTrue(); + } + + @Test + @DisplayName("삭제된 미션이면 예외가 발생한다") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + ReflectionTestUtils.setField(exploreMission1, "deletedAt", LocalDateTime.now()); + + // then + assertThatThrownBy(() -> missionService.completeMission(exploreMission1)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("이미 완료된 미션이면 예외가 발생한다") + void shouldThrowExceptionWhenMissionIsAlreadyCompleted() { + // given + ReflectionTestUtils.setField(exploreMission1, "completed", true); + + // then + assertThatThrownBy(() -> missionService.completeMission(exploreMission1)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_COMPLETED.getMessage()); + } + } + + @Nested + @DisplayName("validateAllMissionsCompletedByStampId 메서드는") + class ValidateAllMissionsCompletedByStampId { + + @Test + @DisplayName("특정 스탬프 하위의 미션이 하나라도 완료되지 않았다면 예외가 발생한다.") + void shouldThrowExceptionWhenAnyMissionIsNotCompleted() { + // given + Long stampId = courseStamp.getId(); + given( + missionQueryRepository + .existsByStampIdAndCompletedIsFalseAndDeletedAtIsNull(stampId)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> missionService.validateAllMissionsCompletedByStampId(stampId)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.ALL_MISSIONS_NOT_COMPLETED.getMessage()); + } + + @Test + @DisplayName("특정 스탬프 하위의 모든 미션이 완료되면 예외가 발생하지 않는다.") + void shouldPassWhenAllMissionsAreCompleted() { + // given + Long stampId = courseStamp.getId(); + given( + missionQueryRepository + .existsByStampIdAndCompletedIsFalseAndDeletedAtIsNull(stampId)) + .willReturn(false); + + // when & then + assertDoesNotThrow(() -> missionService.validateAllMissionsCompletedByStampId(stampId)); + } + } } diff --git a/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java index bc0a479..54d30dd 100644 --- a/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java @@ -58,10 +58,6 @@ class MissionControllerIntegrationTest extends BaseIntegrationTest { private Mission exploreMission1; private Mission exploreMission2; - private Trip deletedTrip; - private Stamp deletedStamp; - private Mission deletedMission; - private String newAccessToken; private Stamp newStamp; private Mission newMission; @@ -81,10 +77,6 @@ void setUp() { exploreMission1 = missionTestHelper.saveMission(exploreStamp, 1); exploreMission2 = missionTestHelper.saveMission(exploreStamp, 2); - deletedTrip = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); - deletedStamp = stampTestHelper.saveDeletedStamp(courseTrip, 3); - deletedMission = missionTestHelper.saveDeletedMission(courseStamp, 1); - Member newMember = memberTestHelper.saveMember("test@kakao.com", "TEST NICKNAME"); newAccessToken = tokenTestHelper.createAccessToken( @@ -290,12 +282,12 @@ void shouldReturnBadRequestWhenOrderIsLessThanOne() throws Exception { @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { // given + courseTrip.updateDeletedAt(); CreateMissionRequest request = fixture.withMissionOrder(0).build(); // when ResultActions resultActions = - getResultActions( - accessToken, deletedTrip.getId(), courseStamp.getId(), request); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); // then resultActions @@ -310,12 +302,12 @@ void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { // given + courseStamp.updateDeletedAt(); CreateMissionRequest request = fixture.withMissionOrder(0).build(); // when ResultActions resultActions = - getResultActions( - accessToken, courseTrip.getId(), deletedStamp.getId(), request); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); // then resultActions @@ -587,13 +579,14 @@ void shouldReturnBadRequestWhenMissionIdTypeMismatch() throws Exception { @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { // given + courseTrip.updateDeletedAt(); UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); // when ResultActions resultActions = getResultActions( accessToken, - deletedTrip.getId(), + courseTrip.getId(), courseStamp.getId(), courseMission1.getId(), request); @@ -611,6 +604,7 @@ void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { // given + courseStamp.updateDeletedAt(); UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); // when @@ -618,7 +612,7 @@ void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { getResultActions( accessToken, courseTrip.getId(), - deletedStamp.getId(), + courseStamp.getId(), courseMission1.getId(), request); @@ -638,6 +632,7 @@ void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { @DisplayName("삭제된 미션일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { // given + courseMission1.updateDeletedAt(); UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); // when @@ -646,7 +641,7 @@ void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { accessToken, courseTrip.getId(), courseStamp.getId(), - deletedMission.getId(), + courseMission1.getId(), request); // then @@ -975,13 +970,13 @@ void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { // given + courseTrip.updateDeletedAt(); List ids = List.of(courseMission2.getId(), courseMission1.getId()); UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); // when ResultActions resultActions = - getResultActions( - accessToken, deletedTrip.getId(), courseStamp.getId(), request); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); // then resultActions @@ -996,13 +991,13 @@ void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { // given + courseStamp.updateDeletedAt(); List ids = List.of(courseMission2.getId(), courseMission1.getId()); UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); // when ResultActions resultActions = - getResultActions( - accessToken, courseTrip.getId(), deletedStamp.getId(), request); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); // then resultActions @@ -1020,7 +1015,8 @@ void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { @DisplayName("삭제된 미션이 포함되어 있다면 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { // given - List ids = List.of(courseMission2.getId(), deletedMission.getId()); + courseMission1.updateDeletedAt(); + List ids = List.of(courseMission2.getId(), courseMission1.getId()); UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); // when @@ -1349,11 +1345,14 @@ void shouldReturnBadRequestWhenMissionIdTypeMismatch() throws Exception { @Test @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + courseTrip.updateDeletedAt(); + // when ResultActions resultActions = getResultActions( accessToken, - deletedTrip.getId(), + courseTrip.getId(), courseStamp.getId(), courseMission2.getId()); @@ -1369,12 +1368,15 @@ void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { @Test @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // given + courseStamp.updateDeletedAt(); + // when ResultActions resultActions = getResultActions( accessToken, courseTrip.getId(), - deletedStamp.getId(), + courseStamp.getId(), courseMission2.getId()); // then @@ -1392,13 +1394,16 @@ void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { @Test @DisplayName("삭제된 미션일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { + // given + courseMission1.updateDeletedAt(); + // when ResultActions resultActions = getResultActions( accessToken, courseTrip.getId(), courseStamp.getId(), - deletedMission.getId()); + courseMission1.getId()); // then resultActions @@ -1640,9 +1645,12 @@ void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { @Test @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + courseTrip.updateDeletedAt(); + // when ResultActions resultActions = - getResultActions(accessToken, deletedTrip.getId(), courseStamp.getId()); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId()); // then resultActions @@ -1656,9 +1664,12 @@ void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { @Test @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // given + courseStamp.updateDeletedAt(); + // when ResultActions resultActions = - getResultActions(accessToken, courseTrip.getId(), deletedStamp.getId()); + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId()); // then resultActions diff --git a/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java b/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java index 7c78adf..337ce3b 100644 --- a/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java +++ b/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; @@ -28,6 +29,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -588,4 +590,63 @@ void shouldThrowExceptionWhenStampIsDeleted() { .hasMessage(StampErrorCode.STAMP_ALREADY_DELETED.getMessage()); } } + + @Nested + @DisplayName("completeStamp 메서드는") + class CompleteStamp { + + @Test + @DisplayName("이미 완료된 스탬프이면 예외가 발생한다.") + void shouldThrowExceptionWhenStampIsAlreadyCompleted() { + // given + ReflectionTestUtils.setField(courseStamp1, "completed", true); + + // when & then + assertThatThrownBy(() -> stampService.completeStamp(courseStamp1)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_ALREADY_COMPLETED.getMessage()); + } + + @Test + @DisplayName("유효한 스탬프가 들어오면, completed 필드를 true로 업데이트한다.") + void shouldCompleteStamp() { + // when + stampService.completeStamp(courseStamp1); + + // then + assertThat(courseStamp1.isCompleted()).isTrue(); + } + } + + @Nested + @DisplayName("validateAllStampsCompletedByTripId 메서드는") + class ValidateAllStampsCompletedByTripId { + + @Test + @DisplayName("특정 여행 하위의 스탬프가 하나라도 완료되지 않았다면 예외가 발생한다.") + void shouldThrowExceptionWhenAnyStampIsNotCompleted() { + // given + Long tripId = courseTrip.getId(); + given(stampQueryRepository.existsByTripIdAndCompletedIsFalseAndDeletedAtIsNull(tripId)) + .willReturn(true); + + // when & then + Assertions.assertThatThrownBy( + () -> stampService.validateAllStampsCompletedByTripId(tripId)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.ALL_STAMPS_NOT_COMPLETED.getMessage()); + } + + @Test + @DisplayName("특정 여행 하위의 모든 스탬프가 완료되면 예외가 발생하지 않는다.") + void shouldPassWhenAllStampsAreCompleted() { + // given + Long tripId = courseTrip.getId(); + given(stampQueryRepository.existsByTripIdAndCompletedIsFalseAndDeletedAtIsNull(tripId)) + .willReturn(false); + + // when & then + assertDoesNotThrow(() -> stampService.validateAllStampsCompletedByTripId(tripId)); + } + } } diff --git a/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java index 2466c62..a3aa660 100644 --- a/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java @@ -11,6 +11,9 @@ import com.ject.studytrip.global.exception.error.CommonErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.helper.MemberTestHelper; +import com.ject.studytrip.mission.domain.error.MissionErrorCode; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.helper.MissionTestHelper; import com.ject.studytrip.stamp.domain.error.StampErrorCode; import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.stamp.fixture.CreateStampRequestFixture; @@ -30,6 +33,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; @@ -40,6 +44,7 @@ public class StampControllerIntegrationTest extends BaseIntegrationTest { @Autowired private MemberTestHelper memberTestHelper; @Autowired private TripTestHelper tripTestHelper; @Autowired private StampTestHelper stampTestHelper; + @Autowired private MissionTestHelper missionTestHelper; @Autowired private TokenTestHelper tokenTestHelper; private String token; @@ -48,6 +53,10 @@ public class StampControllerIntegrationTest extends BaseIntegrationTest { private Trip exploreTrip; private Stamp courseStamp1; private Stamp courseStamp2; + private Mission courseMission1; + private Mission courseMission2; + + private String newToken; @BeforeEach void setup() { @@ -59,6 +68,13 @@ void setup() { exploreTrip = tripTestHelper.saveTrip(member, TripCategory.EXPLORE); courseStamp1 = stampTestHelper.saveStamp(courseTrip, 1); courseStamp2 = stampTestHelper.saveStamp(courseTrip, 2); + courseMission1 = missionTestHelper.saveMission(courseStamp1, 1); + courseMission2 = missionTestHelper.saveMission(courseStamp1, 2); + + Member newMember = memberTestHelper.saveMember("test@kakao.com", "TEST NICKNAME"); + newToken = + tokenTestHelper.createAccessToken( + newMember.getId().toString(), newMember.getRole().name()); } @Nested @@ -261,7 +277,7 @@ class UpdateStamp { @Nested @DisplayName("스탬프 이름 수정") - class UpdateNameAndDeadline { + class UpdateName { private ResultActions getResultActions( String token, Object tripId, Object stampId, UpdateStampRequest request) throws Exception { @@ -624,7 +640,6 @@ void shouldThrowExceptionWhenAlreadyDeletedTrip() throws Exception { @DisplayName("탐험형 여행이지만 스탬프 순서 변경을 요청한 경우 400 예외가 발생한다") void shouldThrowExceptionWhenRequestUpdateStampOrderForExploreTrip() throws Exception { // given - Stamp exploreStamp = stampTestHelper.saveStamp(exploreTrip, 0); UpdateStampOrderRequest request = updateStampRequestFixture.buildUpdateOrders(); // when @@ -1216,4 +1231,267 @@ void shouldThrowExceptionWhenAlreadyDeletedStamp() throws Exception { .value())); } } + + @Nested + @DisplayName("스탬프 완료 API") + class CompleteStamp { + private ResultActions getResultActions(String token, Object tripId, Object stampId) + throws Exception { + return mockMvc.perform( + patch("/api/trips/{tripId}/stamps/{stampId}/complete", tripId, stampId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = + getResultActions("", courseTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + + // when + ResultActions resultActions = + getResultActions(token, invalidTripId, courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getMessage())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), invalidStampId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getMessage())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + courseTrip.updateDeletedAt(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getMessage())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // when + ResultActions resultActions = + getResultActions(newToken, courseTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.NOT_TRIP_OWNER.getMessage())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // when + ResultActions resultActions = + getResultActions(token, exploreTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(StampErrorCode.STAMP_NOT_BELONG_TO_TRIP.getMessage())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + + // when + ResultActions resultActions = + getResultActions(token, invalidTripId, courseStamp1.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), invalidStampId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(StampErrorCode.STAMP_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("특정 스탬프 하위의 미션이 하나라도 완료되지 않았다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenAnyMissionIsNotCompleted() throws Exception { + // given + courseMission1.updateCompleted(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.ALL_MISSIONS_NOT_COMPLETED + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + MissionErrorCode.ALL_MISSIONS_NOT_COMPLETED + .getMessage())); + } + + @Test + @DisplayName("스탬프가 이미 완료되었다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyCompleted() throws Exception { + // given + courseMission1.updateCompleted(); + courseMission2.updateCompleted(); + courseStamp1.updateCompleted(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), courseStamp1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_COMPLETED + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(StampErrorCode.STAMP_ALREADY_COMPLETED.getMessage())); + } + + @Test + @DisplayName("특정 스탬프 하위의 모든 미션이 완료되었다면 스탬프를 완료합니다.") + void shouldCompleteStamp() throws Exception { + // given + courseMission1.updateCompleted(); + courseMission2.updateCompleted(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), courseStamp2.getId()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java index 81e3b8e..f1b0116 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java @@ -33,6 +33,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; +import org.springframework.test.util.ReflectionTestUtils; @DisplayName("TripService 단위 테스트") public class TripServiceTest extends BaseUnitTest { @@ -346,4 +347,46 @@ void shouldReturnTripCountByCategory() { assertThat(result.explore()).isEqualTo(2L); } } + + @Nested + @DisplayName("completeTrip 메서드는") + class CompleteTrip { + + @Test + @DisplayName("이미 완료된 여행이면 예외가 발생한다.") + void shouldThrowExceptionWhenTripIsAlreadyCompleted() { + // given + ReflectionTestUtils.setField(trip, "completed", true); + + // when & then + assertThatThrownBy(() -> tripService.completeTrip(trip)) + .isInstanceOf(CustomException.class) + .hasMessage(TripErrorCode.TRIP_ALREADY_COMPLETED.getMessage()); + } + + @Test + @DisplayName("유효한 여행이 들어오면, completed 필드를 true로 업데이트한다.") + void shouldCompleteStamp() { + // when + tripService.completeTrip(trip); + + // then + assertThat(trip.isCompleted()).isTrue(); + } + } + + @Nested + @DisplayName("increaseCompletedStamps 메서드는") + class IncreaseCompletedStamps { + + @Test + @DisplayName("유효한 여행이 들어오면, Trip의 completedStamps 필드를 1 증가시킨다.") + void shouldIncreaseCompletedStamps() { + // when + tripService.increaseCompletedStamps(trip); + + // then + assertThat(trip.getCompletedStamps()).isEqualTo(1); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java index bfdb056..257eb82 100644 --- a/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java @@ -3,17 +3,21 @@ import static com.ject.studytrip.auth.fixture.TokenFixture.TOKEN_PREFIX; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.ject.studytrip.BaseIntegrationTest; import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.fixture.TokenFixture; import com.ject.studytrip.auth.helper.TokenTestHelper; import com.ject.studytrip.global.common.response.StandardResponse; import com.ject.studytrip.global.exception.error.CommonErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.helper.MemberTestHelper; import com.ject.studytrip.stamp.domain.error.StampErrorCode; +import com.ject.studytrip.stamp.domain.model.Stamp; import com.ject.studytrip.stamp.fixture.CreateStampRequestFixture; +import com.ject.studytrip.stamp.helper.StampTestHelper; import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.model.Trip; @@ -34,6 +38,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; @@ -45,10 +50,14 @@ public class TripControllerIntegrationTest extends BaseIntegrationTest { @Autowired private TokenTestHelper tokenTestHelper; @Autowired private MemberTestHelper memberTestHelper; @Autowired private TripTestHelper tripTestHelper; + @Autowired private StampTestHelper stampTestHelper; private Member member; private Trip trip; private String token; + private String newToken; + private Stamp stamp1; + private Stamp stamp2; @BeforeEach void setup() { @@ -57,6 +66,13 @@ void setup() { tokenTestHelper.createAccessToken( member.getId().toString(), member.getRole().name()); trip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stamp1 = stampTestHelper.saveStamp(trip, 1); + stamp2 = stampTestHelper.saveStamp(trip, 2); + + Member newMember = memberTestHelper.saveMember("test@kakao.com", "TEST NICKNAME"); + newToken = + tokenTestHelper.createAccessToken( + newMember.getId().toString(), newMember.getRole().name()); } @Nested @@ -638,4 +654,186 @@ void shouldThrowExceptionWhenPagingParameterIsInvalid() throws Exception { status().is(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getStatus().value())); } } + + @Nested + @DisplayName("여행 완료 API") + class CompleteTrip { + private ResultActions getResultActions(String token, Object tripId) throws Exception { + return mockMvc.perform( + patch("/api/trips/{tripId}/complete", tripId) + .header( + org.apache.http.HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = getResultActions("", trip.getId()); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, invalidTripId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getMessage())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + trip.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getMessage())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // when + ResultActions resultActions = getResultActions(newToken, trip.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.NOT_TRIP_OWNER.getMessage())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, invalidTripId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("특정 여행 하위의 스탬프가 하나라도 완료되지 않았다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenAnyStampIsNotCompleted() throws Exception { + // given + + // when + ResultActions resultActions = getResultActions(token, trip.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.ALL_STAMPS_NOT_COMPLETED + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(StampErrorCode.ALL_STAMPS_NOT_COMPLETED.getMessage())); + } + + @Test + @DisplayName("여행이 이미 완료되었다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyCompleted() throws Exception { + // given + stamp1.updateCompleted(); + stamp2.updateCompleted(); + trip.updateCompleted(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + TripErrorCode.TRIP_ALREADY_COMPLETED + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_ALREADY_COMPLETED.getMessage())); + } + + @Test + @DisplayName("특정 여행 하위의 모든 스탬프가 완료되었다면 여행을 완료합니다.") + void shouldCompleteTrip() throws Exception { + // given + stamp1.updateCompleted(); + stamp2.updateCompleted(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } }