diff --git a/src/main/java/com/ject/.DS_Store b/src/main/java/com/ject/.DS_Store deleted file mode 100644 index 6fed5ba..0000000 Binary files a/src/main/java/com/ject/.DS_Store and /dev/null differ diff --git a/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java new file mode 100644 index 0000000..ba65ea5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java @@ -0,0 +1,70 @@ +package com.ject.studytrip.mission.application.facade; + +import com.ject.studytrip.mission.application.dto.MissionInfo; +import com.ject.studytrip.mission.application.service.MissionService; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.stamp.application.service.StampService; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.trip.application.service.TripService; +import com.ject.studytrip.trip.domain.model.Trip; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MissionFacade { + private final TripService tripService; + private final StampService stampService; + private final MissionService missionService; + + public MissionInfo createMission( + Long memberId, Long tripId, Long stampId, CreateMissionRequest request) { + Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); + Mission mission = missionService.createMission(stamp, request); + + return MissionInfo.from(mission); + } + + public void updateMissionNameAndMemo( + Long memberId, + Long tripId, + Long stampId, + Long missionId, + UpdateMissionRequest request) { + Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); + Mission mission = missionService.getValidMission(stamp.getId(), missionId); + + missionService.updateMissionNameAndMemo(stamp.getId(), mission, request); + } + + public void updateMissionOrders( + Long memberId, Long tripId, Long stampId, UpdateMissionOrderRequest request) { + Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); + + missionService.updateMissionOrders(stamp.getId(), request); + } + + public void deleteMission(Long memberId, Long tripId, Long stampId, Long missionId) { + Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); + Mission mission = missionService.getValidMission(stamp.getId(), missionId); + + missionService.deleteMission(stamp.getId(), mission); + } + + public List getMissionsByStamp(Long memberId, Long tripId, Long stampId) { + Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); + List missions = missionService.getMissionsByStampId(stamp.getId()); + + return missions.stream().map(MissionInfo::from).toList(); + } + + private Stamp getValidStampFromTripOwnedByMember(Long memberId, Long tripId, Long stampId) { + Trip trip = tripService.getValidTrip(memberId, tripId); + + return stampService.getValidStamp(trip.getId(), stampId); + } +} 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 9be7425..23d0c7c 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 @@ -1,20 +1,105 @@ package com.ject.studytrip.mission.application.service; +import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.mission.application.dto.MissionInfo; +import com.ject.studytrip.mission.domain.error.MissionErrorCode; +import com.ject.studytrip.mission.domain.factory.MissionFactory; import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.policy.MissionPolicy; import com.ject.studytrip.mission.domain.repository.MissionRepository; +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.stamp.domain.model.Stamp; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class MissionService { private final MissionRepository missionRepository; + @Transactional + public Mission createMission(Stamp stamp, CreateMissionRequest request) { + boolean exists = + missionRepository.existsByStampIdAndMissionOrderAndDeletedAtIsNull( + stamp.getId(), request.order()); + MissionPolicy.validateOrderNotDuplicated(exists); + + Mission mission = + MissionFactory.create(stamp, request.name(), request.memo(), request.order()); + + return missionRepository.save(mission); + } + + @Transactional + public void updateMissionNameAndMemo( + Long stampId, Mission mission, UpdateMissionRequest request) { + validateMissionIsActiveAndBelongsToStamp(stampId, mission); + + mission.update(request.name(), request.memo()); + } + + @Transactional + public void updateMissionOrders(Long stampId, UpdateMissionOrderRequest request) { + // 요청된 ID 목록에 해당하는 미션 조회 + List orderedMissionIds = request.orderedMissionIds(); + List missions = missionRepository.findAllByIdIn(orderedMissionIds); + + // 정책 검증 + missions.forEach(mission -> validateMissionIsActiveAndBelongsToStamp(stampId, mission)); + MissionPolicy.validateMissionOrders(orderedMissionIds, missions); + + // ID -> 미션 맵 생성 + Map missionMap = + missions.stream().collect(Collectors.toMap(Mission::getId, Function.identity())); + + // 미션 순서 업데이트 + for (int i = 0; i < orderedMissionIds.size(); i++) { + Long missionId = orderedMissionIds.get(i); + Mission mission = missionMap.get(missionId); + mission.updateMissionOrder(i + 1); + } + } + + @Transactional + public void deleteMission(Long stampId, Mission mission) { + validateMissionIsActiveAndBelongsToStamp(stampId, mission); + + mission.updateDeletedAt(); + } + + @Transactional(readOnly = true) public List getMissionsByStamp(Long stampId) { List missions = missionRepository.findAllByStampIdOrderByMissionOrder(stampId); return missions.stream().map(MissionInfo::from).toList(); } + + @Transactional(readOnly = true) + public List getMissionsByStampId(Long stampId) { + return missionRepository.findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(stampId); + } + + @Transactional(readOnly = true) + public Mission getValidMission(Long stampId, Long missionId) { + Mission mission = + missionRepository + .findById(missionId) + .orElseThrow(() -> new CustomException(MissionErrorCode.MISSION_NOT_FOUND)); + + validateMissionIsActiveAndBelongsToStamp(stampId, mission); + + return mission; + } + + 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 new file mode 100644 index 0000000..0e4b755 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java @@ -0,0 +1,40 @@ +package com.ject.studytrip.mission.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum MissionErrorCode implements ErrorCode { + // 400 + MISSION_ORDER_IDS_DUPLICATED(HttpStatus.BAD_REQUEST, "요청한 미션 ID 목록에 중복이 존재합니다."), + MISSION_ORDER_SIZE_MISMATCHED(HttpStatus.BAD_REQUEST, "요청한 미션 수의 크기가 기존 미션 수의 크기와 일치하지 않습니다."), + MISSION_ORDER_IDS_NOT_MATCHED(HttpStatus.BAD_REQUEST, "요청한 미션 ID 목록이 기존 미션 목록과 일치하지 않습니다."), + MISSION_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 미션은 이미 삭제되었습니다."), + MISSION_ORDER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 미션 순서입니다."), + + // 403 + MISSION_NOT_BELONGS_TO_STAMP(HttpStatus.FORBIDDEN, "해당 미션은 요청한 스탬프에 속하지 않습니다."), + + // 404 + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 미션이 존재하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/factory/MissionFactory.java b/src/main/java/com/ject/studytrip/mission/domain/factory/MissionFactory.java new file mode 100644 index 0000000..efb09b6 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/factory/MissionFactory.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.mission.domain.factory; + +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.stamp.domain.model.Stamp; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MissionFactory { + public static Mission create(Stamp stamp, String name, String memo, int missionOrder) { + return Mission.of(stamp, name, memo, missionOrder); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java b/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java index fcb0ec6..53efd4b 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java +++ b/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java @@ -1,8 +1,11 @@ package com.ject.studytrip.mission.domain.model; +import static org.springframework.util.StringUtils.hasText; + import com.ject.studytrip.global.common.entity.BaseTimeEntity; import com.ject.studytrip.stamp.domain.model.Stamp; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -23,16 +26,36 @@ public class Mission extends BaseTimeEntity { @Column(nullable = false) private String name; + private String memo; + private int missionOrder; private boolean completed; - public static Mission of(Stamp stamp, String name, int missionOrder) { + public static Mission of(Stamp stamp, String name, String memo, int missionOrder) { return Mission.builder() .stamp(stamp) .name(name) + .memo(memo) .missionOrder(missionOrder) .completed(false) .build(); } + + public void update(String name, String memo) { + if (hasText(name)) { + this.name = name; + } + if (hasText(memo)) { + this.memo = memo; + } + } + + public void updateMissionOrder(int missionOrder) { + this.missionOrder = missionOrder; + } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } 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 new file mode 100644 index 0000000..d51f197 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java @@ -0,0 +1,56 @@ +package com.ject.studytrip.mission.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.mission.domain.error.MissionErrorCode; +import com.ject.studytrip.mission.domain.model.Mission; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MissionPolicy { + + public static void validateMissionOrders( + List orderedMissionIds, List savedMissions) { + // #1: 중복 미션 ID 검증 + Set uniqueIds = new HashSet<>(orderedMissionIds); + + if (uniqueIds.size() != orderedMissionIds.size()) { + throw new CustomException(MissionErrorCode.MISSION_ORDER_IDS_DUPLICATED); + } + + // #2: 미션 개수 검증 + if (orderedMissionIds.size() != savedMissions.size()) { + throw new CustomException(MissionErrorCode.MISSION_ORDER_SIZE_MISMATCHED); + } + + // #3: 요청 ID와 실제 저장된 미션 ID가 정확히 일치하는지 확인 + Set existingIds = + savedMissions.stream().map(Mission::getId).collect(Collectors.toSet()); + + if (!existingIds.equals(uniqueIds)) { + throw new CustomException(MissionErrorCode.MISSION_ORDER_IDS_NOT_MATCHED); + } + } + + public static void validateMissionBelongsToStamp(Long stampId, Mission mission) { + if (!mission.getStamp().getId().equals(stampId)) { + throw new CustomException(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP); + } + } + + public static void validateNotDeleted(Mission mission) { + if (mission.getDeletedAt() != null) { + throw new CustomException(MissionErrorCode.MISSION_ALREADY_DELETED); + } + } + + public static void validateOrderNotDuplicated(boolean exists) { + if (exists) { + throw new CustomException(MissionErrorCode.MISSION_ORDER_ALREADY_EXISTS); + } + } +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionRepository.java index 0adf441..f3f6d8d 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionRepository.java @@ -2,7 +2,18 @@ import com.ject.studytrip.mission.domain.model.Mission; import java.util.List; +import java.util.Optional; public interface MissionRepository { List findAllByStampIdOrderByMissionOrder(Long stampId); + + List findAllByIdIn(List ids); + + List findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(Long stampId); + + Optional findById(Long id); + + boolean existsByStampIdAndMissionOrderAndDeletedAtIsNull(Long stampId, int missionOrder); + + Mission save(Mission mission); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionJpaRepository.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionJpaRepository.java index 2475e3c..eb23f28 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionJpaRepository.java +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionJpaRepository.java @@ -6,4 +6,10 @@ public interface MissionJpaRepository extends JpaRepository { List findAllByStampIdOrderByMissionOrder(Long stampId); + + List findAllByIdIn(List ids); + + List findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(Long stampId); + + boolean existsByStampIdAndMissionOrderAndDeletedAtIsNull(Long stampId, int missionOrder); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionRepositoryAdapter.java index 4c9296c..b8ecaa4 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionRepositoryAdapter.java @@ -3,6 +3,7 @@ import com.ject.studytrip.mission.domain.model.Mission; import com.ject.studytrip.mission.domain.repository.MissionRepository; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -15,4 +16,31 @@ public class MissionRepositoryAdapter implements MissionRepository { public List findAllByStampIdOrderByMissionOrder(Long stampId) { return missionJpaRepository.findAllByStampIdOrderByMissionOrder(stampId); } + + @Override + public List findAllByIdIn(List ids) { + return missionJpaRepository.findAllByIdIn(ids); + } + + @Override + public List findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(Long stampId) { + return missionJpaRepository.findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(stampId); + } + + @Override + public Optional findById(Long id) { + return missionJpaRepository.findById(id); + } + + @Override + public boolean existsByStampIdAndMissionOrderAndDeletedAtIsNull( + Long stampId, int missionOrder) { + return missionJpaRepository.existsByStampIdAndMissionOrderAndDeletedAtIsNull( + stampId, missionOrder); + } + + @Override + public Mission save(Mission mission) { + return missionJpaRepository.save(mission); + } } diff --git a/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java b/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java new file mode 100644 index 0000000..6aff1d9 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java @@ -0,0 +1,104 @@ +package com.ject.studytrip.mission.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.mission.application.dto.MissionInfo; +import com.ject.studytrip.mission.application.facade.MissionFacade; +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.response.CreateMissionResponse; +import com.ject.studytrip.mission.presentation.dto.response.LoadMissionInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Mission", description = "미션 API") +@RequestMapping("/api") +@RestController +@RequiredArgsConstructor +@Validated +public class MissionController { + private final MissionFacade missionFacade; + + @Operation(summary = "미션 생성", description = "특정 스탬프에 새로운 미션을 생성합니다.") + @PostMapping("/trips/{tripId}/stamps/{stampId}/missions") + public ResponseEntity createMission( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId, + @RequestBody @Valid CreateMissionRequest request) { + MissionInfo result = + missionFacade.createMission(Long.valueOf(memberId), tripId, stampId, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + StandardResponse.success( + HttpStatus.CREATED.value(), CreateMissionResponse.of(result))); + } + + @Operation(summary = "미션 수정", description = "특정 미션의 이름 또는 메모를 수정합니다.") + @PatchMapping("/trips/{tripId}/stamps/{stampId}/missions/{missionId}") + public ResponseEntity updateMission( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId, + @PathVariable @NotNull(message = "미션 ID는 필수 요청 파라미터입니다.") Long missionId, + @RequestBody UpdateMissionRequest request) { + missionFacade.updateMissionNameAndMemo( + Long.valueOf(memberId), tripId, stampId, missionId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation( + summary = "미션 순서 변경", + description = "코스형과 탐험형 스탬프 모두 미션 순서를 가지며, 요청된 미션 ID 목록의 순서대로 미션 순서를 변경합니다.") + @PutMapping("/trips/{tripId}/stamps/{stampId}/missions/orders") + public ResponseEntity updateMissionOrders( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId, + @RequestBody @Valid UpdateMissionOrderRequest request) { + missionFacade.updateMissionOrders(Long.valueOf(memberId), tripId, stampId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "미션 삭제", description = "특정 미션을 삭제합니다.") + @DeleteMapping("/trips/{tripId}/stamps/{stampId}/missions/{missionId}") + public ResponseEntity deleteMission( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId, + @PathVariable @NotNull(message = "미션 ID는 필수 요청 파라미터입니다.") Long missionId) { + missionFacade.deleteMission(Long.valueOf(memberId), tripId, stampId, missionId); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "미션 목록 조회", description = "특정 스탬프의 미션 목록을 조회합니다.") + @GetMapping("/trips/{tripId}/stamps/{stampId}/missions") + public ResponseEntity loadMissionsByStamp( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId) { + List results = + missionFacade.getMissionsByStamp(Long.valueOf(memberId), tripId, stampId); + List responses = + results.stream().map(LoadMissionInfoResponse::of).toList(); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), responses)); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/presentation/dto/request/CreateMissionRequest.java b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/CreateMissionRequest.java new file mode 100644 index 0000000..f010863 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/CreateMissionRequest.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.mission.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record CreateMissionRequest( + @Schema(description = "미션 이름") @NotBlank(message = "미션 이름은 필수 요청 값입니다.") String name, + @Schema(description = "미션 메모") String memo, + @Schema(description = "미션 순서") @Min(value = 1, message = "모든 미션 순서는 최소 1 이상이어야 합니다.") + int order) {} diff --git a/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionOrderRequest.java b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionOrderRequest.java new file mode 100644 index 0000000..8b2c2a5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionOrderRequest.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.mission.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record UpdateMissionOrderRequest( + @Schema(description = "변경된 순서를 반영한 미션 ID 목록 (앞에서부터 순서대로 정렬)") + @NotEmpty(message = "미션 ID 목록은 필수 요청 값입니다.") + List<@NotNull(message = "미션 ID는 null일 수 없습니다.") Long> orderedMissionIds) {} diff --git a/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java new file mode 100644 index 0000000..007b881 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.mission.presentation.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UpdateMissionRequest( + @Schema(description = "수정할 미션 이름") String name, + @Schema(description = "수정할 미션 메모") String memo) {} diff --git a/src/main/java/com/ject/studytrip/mission/presentation/dto/response/CreateMissionResponse.java b/src/main/java/com/ject/studytrip/mission/presentation/dto/response/CreateMissionResponse.java new file mode 100644 index 0000000..6bae2b5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/presentation/dto/response/CreateMissionResponse.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.mission.presentation.dto.response; + +import com.ject.studytrip.mission.application.dto.MissionInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateMissionResponse(@Schema(description = "미션 ID") Long missionId) { + public static CreateMissionResponse of(MissionInfo info) { + return new CreateMissionResponse(info.missionId()); + } +} 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 c07a1fd..cd4d1aa 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 @@ -46,7 +46,7 @@ public void updateStampNameAndDeadline( public void updateStampOrders(Long memberId, Long tripId, UpdateStampOrderRequest request) { Trip trip = tripService.getValidTrip(memberId, tripId); - stampService.updateStampsOrders(trip, request); + stampService.updateStampOrders(trip, request); } @Transactional 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 13c3c94..d193e7e 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 @@ -68,7 +68,7 @@ public void updateStampNameAndDeadline( StampPolicy.validateStampDeadline(trip.getEndDate(), List.of(stamp)); } - public void updateStampsOrders(Trip trip, UpdateStampOrderRequest request) { + public void updateStampOrders(Trip trip, UpdateStampOrderRequest request) { // 배치 조회 (ID 목록 기준으로 조회하지만 순서는 보장되지 않음) List stamps = stampRepository.findAllByIdIn(request.orderedStampIds()); @@ -93,7 +93,7 @@ public void updateStampsOrders(Trip trip, UpdateStampOrderRequest request) { } } - public void updateStampsOrderByTripCategoryChange(Long tripId, TripCategory newCategory) { + public void updateStampOrdersByTripCategoryChange(Long tripId, TripCategory newCategory) { List stamps = stampRepository.findAllByTripIdOrderByDeadlineAsc(tripId); if (newCategory == TripCategory.EXPLORE) { 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 3c3d896..1849ec5 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 @@ -52,7 +52,7 @@ public void updateTrip(Long memberId, Long tripId, UpdateTripRequest request) { tripService.updateTrip(member.getId(), trip, request); if (request.category() != null) - stampService.updateStampsOrderByTripCategoryChange( + stampService.updateStampOrdersByTripCategoryChange( trip.getId(), TripCategory.from(request.category())); } diff --git a/src/test/java/com/ject/.DS_Store b/src/test/java/com/ject/.DS_Store deleted file mode 100644 index 6fed5ba..0000000 Binary files a/src/test/java/com/ject/.DS_Store and /dev/null differ 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 new file mode 100644 index 0000000..3f349b5 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java @@ -0,0 +1,458 @@ +package com.ject.studytrip.mission.application.service; + +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 com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.mission.domain.error.MissionErrorCode; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.repository.MissionRepository; +import com.ject.studytrip.mission.fixture.CreateMissionRequestFixture; +import com.ject.studytrip.mission.fixture.MissionFixture; +import com.ject.studytrip.mission.fixture.UpdateMissionOrderRequestFixture; +import com.ject.studytrip.mission.fixture.UpdateMissionRequestFixture; +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.fixture.StampFixture; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.TripFixture; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +class MissionServiceTest extends BaseUnitTest { + private static final String NEW_MISSION_NAME = "NEW MISSION NAME"; + private static final String NEW_MISSION_MEMO = "NEW MISSION MEMO"; + + @InjectMocks private MissionService missionService; + @Mock private MissionRepository missionRepository; + + private Stamp courseStamp; + private Stamp exploreStamp; + private Mission courseMission; + private Mission exploreMission1; + private Mission exploreMission2; + + @BeforeEach + void setUp() { + Member member = MemberFixture.createMemberFromKakao(); + Trip courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + Trip exploreTrip = TripFixture.createTripWithId(2L, member, TripCategory.EXPLORE); + courseStamp = StampFixture.createStampWithId(1L, courseTrip, 1); + exploreStamp = StampFixture.createStampWithId(2L, exploreTrip, 0); + courseMission = MissionFixture.createMissionWithId(1L, courseStamp, 1); + exploreMission1 = MissionFixture.createMissionWithId(2L, exploreStamp, 1); + exploreMission2 = MissionFixture.createMissionWithId(3L, exploreStamp, 2); + } + + @Nested + @DisplayName("createMission 메서드는") + class CreateMission { + private final CreateMissionRequestFixture fixture = new CreateMissionRequestFixture(); + + @Test + @DisplayName("미션 순서가 이미 존재할 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMissionOrderAlreadyExists() { + // given + CreateMissionRequest request = fixture.build(); + given( + missionRepository.existsByStampIdAndMissionOrderAndDeletedAtIsNull( + courseStamp.getId(), request.order())) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> missionService.createMission(courseStamp, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ORDER_ALREADY_EXISTS.getMessage()); + } + + @Test + @DisplayName("코스형 여행 스탬프를 위한 미션을 생성하고 반환한다.") + void shouldReturnMissionForCourseStamp() { + // given + CreateMissionRequest request = fixture.build(); + given(missionRepository.save(any(Mission.class))).willReturn(courseMission); + + // when + Mission result = missionService.createMission(courseStamp, request); + + // then + assertThat(result).isEqualTo(courseMission); + assertThat(result.getStamp()).isEqualTo(courseStamp); + assertThat(result.getMissionOrder()).isEqualTo(request.order()); + } + + @Test + @DisplayName("탐험형 여행 스탬프를 위한 미션을 생성하고 반환한다.") + void shouldReturnMissionForExploreStamp() { + // given + CreateMissionRequest request = fixture.build(); + given(missionRepository.save(any(Mission.class))).willReturn(exploreMission1); + + // when + Mission result = missionService.createMission(exploreStamp, request); + + // then + assertThat(result).isEqualTo(exploreMission1); + assertThat(result.getStamp()).isEqualTo(exploreStamp); + assertThat(result.getMissionOrder()).isEqualTo(request.order()); + } + + @Test + @DisplayName("메모가 없어도 탐험형 여행 스탬프를 위한 미션을 생성하고 반환한다.") + void shouldReturnMissionForExploreStampWithoutMemo() { + // given + CreateMissionRequest request = fixture.withMemo(null).build(); + given(missionRepository.save(any(Mission.class))).willReturn(exploreMission1); + + // when + Mission result = missionService.createMission(exploreStamp, request); + + // then + assertThat(result).isEqualTo(exploreMission1); + assertThat(result.getStamp()).isEqualTo(exploreStamp); + assertThat(result.getMissionOrder()).isEqualTo(request.order()); + } + } + + @Nested + @DisplayName("updateMissionNameAndMemo 메서드는") + class UpdateMissionNameAndMemo { + + @Test + @DisplayName("미션이 다른 스탬프에 속하면 예외가 발생한다.") + void shouldThrowExceptionWhenMissionNotBelongToStamp() { + // given + Long invalidStampId = exploreStamp.getId(); + UpdateMissionRequest request = + new UpdateMissionRequestFixture().withName(NEW_MISSION_NAME).build(); + + // when & then + assertThatThrownBy( + () -> + missionService.updateMissionNameAndMemo( + invalidStampId, courseMission, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP.getMessage()); + } + + @Test + @DisplayName("미션이 이미 삭제된 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + Long stampId = courseStamp.getId(); + ReflectionTestUtils.setField(courseMission, "deletedAt", LocalDateTime.now()); + UpdateMissionRequest request = + new UpdateMissionRequestFixture().withName(NEW_MISSION_NAME).build(); + + // when & then + assertThatThrownBy( + () -> + missionService.updateMissionNameAndMemo( + stampId, courseMission, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("특정 미션의 이름만 수정하고 DB에 반영한다.") + void shouldUpdateMissionName() { + // given + Long stampId = courseStamp.getId(); + UpdateMissionRequest request = + new UpdateMissionRequestFixture().withName(NEW_MISSION_NAME).build(); + + // when + missionService.updateMissionNameAndMemo(stampId, courseMission, request); + + // then + assertThat(courseMission.getName()).isEqualTo(NEW_MISSION_NAME); + } + + @Test + @DisplayName("특정 미션의 메모만 수정하고 DB에 반영한다.") + void shouldUpdateMissionMemo() { + // given + Long stampId = courseStamp.getId(); + UpdateMissionRequest request = + new UpdateMissionRequestFixture().withMemo(NEW_MISSION_MEMO).build(); + + // when + missionService.updateMissionNameAndMemo(stampId, courseMission, request); + + // then + assertThat(courseMission.getMemo()).isEqualTo(NEW_MISSION_MEMO); + } + + @Test + @DisplayName("특정 미션의 이름과 메모를 수정하고 DB에 반영한다.") + void shouldUpdateMissionNameAndMemo() { + // given + Long stampId = courseStamp.getId(); + UpdateMissionRequest request = + new UpdateMissionRequestFixture() + .withName(NEW_MISSION_NAME) + .withMemo(NEW_MISSION_MEMO) + .build(); + + // when + missionService.updateMissionNameAndMemo(stampId, courseMission, request); + + // then + assertThat(courseMission.getName()).isEqualTo(NEW_MISSION_NAME); + assertThat(courseMission.getMemo()).isEqualTo(NEW_MISSION_MEMO); + } + } + + @Nested + @DisplayName("updateMissionOrders 메서드는") + class UpdateMissionOrders { + private final UpdateMissionOrderRequestFixture fixture = + new UpdateMissionOrderRequestFixture(); + + @Test + @DisplayName("미션이 다른 스탬프에 속하면 예외가 발생한다.") + void shouldThrowExceptionWhenMissionNotBelongToStamp() { + // given + Long invalidStampId = courseStamp.getId(); + List ids = List.of(2L, 3L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionOrders(invalidStampId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP.getMessage()); + } + + @Test + @DisplayName("미션이 이미 삭제된 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + Long stampId = exploreStamp.getId(); + ReflectionTestUtils.setField(exploreMission1, "deletedAt", LocalDateTime.now()); + List ids = List.of(2L, 3L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionOrders(stampId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("중복된 미션 ID가 존재하면 예외가 발생한다.") + void shouldThrowExceptionWhenDuplicatedIds() { + // given + Long stampId = exploreStamp.getId(); + List ids = List.of(1L, 1L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)).willReturn(List.of(exploreMission1)); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionOrders(stampId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ORDER_IDS_DUPLICATED.getMessage()); + } + + @Test + @DisplayName("요청된 미션 수가 실제 미션 수와 다르면 예외가 발생한다.") + void shouldThrowExceptionWhenSizeMismatch() { + // given + Long stampId = exploreStamp.getId(); + List ids = List.of(1L, 2L, 3L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionOrders(stampId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ORDER_SIZE_MISMATCHED.getMessage()); + } + + @Test + @DisplayName("요청된 ID와 실제 미션 ID가 일치하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenIdsNotMatched() { + // given + Long stampId = exploreStamp.getId(); + List ids = List.of(1L, 3L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when & then + assertThatThrownBy(() -> missionService.updateMissionOrders(stampId, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ORDER_IDS_NOT_MATCHED.getMessage()); + } + + @Test + @DisplayName("정상적인 요청이 들어오면 미션들의 순서를 갱신한다.") + void shouldUpdateMissionOrders() { + // given + Long stampId = exploreStamp.getId(); + List ids = List.of(2L, 3L); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + given(missionRepository.findAllByIdIn(ids)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when + missionService.updateMissionOrders(stampId, request); + + // then + assertThat(exploreMission1.getMissionOrder()).isEqualTo(1); + assertThat(exploreMission2.getMissionOrder()).isEqualTo(2); + } + } + + @Nested + @DisplayName("deleteMission 메서드는") + class DeleteMission { + + @Test + @DisplayName("미션이 다른 스탬프에 속하면 예외가 발생한다.") + void shouldThrowExceptionWhenMissionNotBelongToStamp() { + // given + Long invalidStampId = courseStamp.getId(); + + // when & then + assertThatThrownBy(() -> missionService.deleteMission(invalidStampId, exploreMission1)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP.getMessage()); + } + + @Test + @DisplayName("미션이 이미 삭제된 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + Long stampId = exploreStamp.getId(); + ReflectionTestUtils.setField(exploreMission1, "deletedAt", LocalDateTime.now()); + + // when & then + assertThatThrownBy(() -> missionService.deleteMission(stampId, exploreMission1)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("미션을 삭제하면 deletedAt 필드에 현재 시각이 설정된다.") + void shouldDeleteMission() { + // given + Long stampId = courseStamp.getId(); + assertThat(courseMission.getDeletedAt()).isNull(); + LocalDateTime beforeDeletionTime = LocalDateTime.now(); + + // when + missionService.deleteMission(stampId, courseMission); + + // then + assertThat(courseMission.getDeletedAt()).isNotNull(); + assertThat(courseMission.getDeletedAt()).isAfterOrEqualTo(beforeDeletionTime); + } + } + + @Nested + @DisplayName("getMissionsByStampId 메서드는") + class GetMissionsByStampId { + + @Test + @DisplayName("특정 스탬프에 대한 삭제되지 않은 모든 미션을 순서대로 반환한다.") + void shouldReturnMissionsInOrderWhenStampIdExists() { + // given + Long stampId = exploreStamp.getId(); + given(missionRepository.findAllByStampIdAndDeletedAtIsNullOrderByMissionOrder(stampId)) + .willReturn(List.of(exploreMission1, exploreMission2)); + + // when + List result = missionService.getMissionsByStampId(stampId); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactly(exploreMission1, exploreMission2); + assertThat(result.get(0).getMissionOrder()).isEqualTo(1); + assertThat(result.get(1).getMissionOrder()).isEqualTo(2); + } + } + + @Nested + @DisplayName("getValidMission 메서드는") + class GetValidMission { + + @Test + @DisplayName("미션이 존재하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenMissionNotFound() { + // given + Long missionId = 99L; + Long stampId = exploreStamp.getId(); + given(missionRepository.findById(missionId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> missionService.getValidMission(stampId, missionId)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("미션이 다른 스탬프에 속하면 예외가 발생한다.") + void shouldThrowExceptionWhenMissionNotBelongToStamp() { + // given + Long missionId = exploreMission1.getId(); + Long invalidStampId = courseStamp.getId(); + given(missionRepository.findById(missionId)).willReturn(Optional.of(exploreMission1)); + + // when & then + assertThatThrownBy(() -> missionService.getValidMission(invalidStampId, missionId)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP.getMessage()); + } + + @Test + @DisplayName("미션이 이미 삭제된 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + Long missionId = exploreMission1.getId(); + Long stampId = exploreStamp.getId(); + ReflectionTestUtils.setField(exploreMission1, "deletedAt", LocalDateTime.now()); + given(missionRepository.findById(missionId)).willReturn(Optional.of(exploreMission1)); + + // when & then + assertThatThrownBy(() -> missionService.getValidMission(stampId, missionId)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("특정 스탬프에 속하고 삭제되지 않은 미션이 존재하면, 해당 미션을 반환한다.") + void shouldReturnMissionWhenValid() { + // given + Long missionId = exploreMission1.getId(); + Long stampId = exploreStamp.getId(); + given(missionRepository.findById(missionId)).willReturn(Optional.of(exploreMission1)); + + // when + Mission result = missionService.getValidMission(stampId, missionId); + + // then + assertThat(result).isEqualTo(exploreMission1); + } + } +} diff --git a/src/test/java/com/ject/studytrip/mission/fixture/CreateMissionRequestFixture.java b/src/test/java/com/ject/studytrip/mission/fixture/CreateMissionRequestFixture.java new file mode 100644 index 0000000..018b599 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/fixture/CreateMissionRequestFixture.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.mission.fixture; + +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; + +public class CreateMissionRequestFixture { + private String name = "TEST MISSION NAME"; + private String memo = "TEST MISSION MEMO"; + private int missionOrder = 1; + + public CreateMissionRequestFixture withName(String name) { + this.name = name; + return this; + } + + public CreateMissionRequestFixture withMemo(String memo) { + this.memo = memo; + return this; + } + + public CreateMissionRequestFixture withMissionOrder(int missionOrder) { + this.missionOrder = missionOrder; + return this; + } + + public CreateMissionRequest build() { + return new CreateMissionRequest(name, memo, missionOrder); + } +} diff --git a/src/test/java/com/ject/studytrip/mission/fixture/MissionFixture.java b/src/test/java/com/ject/studytrip/mission/fixture/MissionFixture.java new file mode 100644 index 0000000..1562ac9 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/fixture/MissionFixture.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.mission.fixture; + +import com.ject.studytrip.mission.domain.factory.MissionFactory; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.stamp.domain.model.Stamp; +import org.springframework.test.util.ReflectionTestUtils; + +public class MissionFixture { + private static final String MISSION_NAME = "TEST MISSION NAME"; + private static final String MISSION_MEMO = "TEST MISSION MEMO"; + + public static Mission createMission(Stamp stamp, int order) { + return MissionFactory.create(stamp, MISSION_NAME, MISSION_MEMO, order); + } + + public static Mission createMissionWithId(Long id, Stamp stamp, int order) { + Mission mission = MissionFactory.create(stamp, MISSION_NAME, MISSION_MEMO, order); + ReflectionTestUtils.setField(mission, "id", id); + + return mission; + } +} diff --git a/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionOrderRequestFixture.java b/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionOrderRequestFixture.java new file mode 100644 index 0000000..15e96eb --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionOrderRequestFixture.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.mission.fixture; + +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import java.util.List; + +public class UpdateMissionOrderRequestFixture { + private List orderedIds; + + public UpdateMissionOrderRequestFixture withOrderedIds(List orderedIds) { + this.orderedIds = orderedIds; + return this; + } + + public UpdateMissionOrderRequest build() { + return new UpdateMissionOrderRequest(orderedIds); + } +} diff --git a/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionRequestFixture.java b/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionRequestFixture.java new file mode 100644 index 0000000..58330fa --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/fixture/UpdateMissionRequestFixture.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.mission.fixture; + +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; + +public class UpdateMissionRequestFixture { + private String name = null; + private String memo = null; + + public UpdateMissionRequestFixture withName(String name) { + this.name = name; + return this; + } + + public UpdateMissionRequestFixture withMemo(String memo) { + this.memo = memo; + return this; + } + + public UpdateMissionRequest build() { + return new UpdateMissionRequest(name, memo); + } +} diff --git a/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java b/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java new file mode 100644 index 0000000..95e8536 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.mission.helper; + +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.repository.MissionRepository; +import com.ject.studytrip.mission.fixture.MissionFixture; +import com.ject.studytrip.stamp.domain.model.Stamp; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class MissionTestHelper { + @Autowired private MissionRepository missionRepository; + + public Mission saveMission(Stamp stamp, int order) { + Mission mission = MissionFixture.createMission(stamp, order); + + return missionRepository.save(mission); + } + + public Mission saveDeletedMission(Stamp stamp, int order) { + Mission mission = MissionFixture.createMission(stamp, order); + mission.updateDeletedAt(); + + return missionRepository.save(mission); + } +} 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 new file mode 100644 index 0000000..afd41b5 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java @@ -0,0 +1,1761 @@ +package com.ject.studytrip.mission.presentation.controller; + +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.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.fixture.CreateMissionRequestFixture; +import com.ject.studytrip.mission.fixture.UpdateMissionOrderRequestFixture; +import com.ject.studytrip.mission.fixture.UpdateMissionRequestFixture; +import com.ject.studytrip.mission.helper.MissionTestHelper; +import com.ject.studytrip.mission.presentation.dto.request.CreateMissionRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionOrderRequest; +import com.ject.studytrip.mission.presentation.dto.request.UpdateMissionRequest; +import com.ject.studytrip.stamp.domain.error.StampErrorCode; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.helper.StampTestHelper; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.helper.TripTestHelper; +import java.util.List; +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.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; + +class MissionControllerIntegrationTest extends BaseIntegrationTest { + private static final String BASE_MISSION_URL = "/api/trips/{tripId}/stamps/{stampId}/missions"; + + @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TokenTestHelper tokenTestHelper; + @Autowired private TripTestHelper tripTestHelper; + @Autowired private StampTestHelper stampTestHelper; + @Autowired private MissionTestHelper missionTestHelper; + + private String accessToken; + private Trip courseTrip; + private Trip exploreTrip; + private Stamp courseStamp; + private Stamp exploreStamp; + private Mission courseMission1; + private Mission courseMission2; + private Mission exploreMission1; + private Mission exploreMission2; + + private Trip deletedTrip; + private Stamp deletedStamp; + private Mission deletedMission; + + private String newAccessToken; + private Stamp newStamp; + private Mission newMission; + + @BeforeEach + void setUp() { + Member member = memberTestHelper.saveMember(); + accessToken = + tokenTestHelper.createAccessToken( + member.getId().toString(), member.getRole().name()); + courseTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + exploreTrip = tripTestHelper.saveTrip(member, TripCategory.EXPLORE); + courseStamp = stampTestHelper.saveStamp(courseTrip, 3); + exploreStamp = stampTestHelper.saveStamp(exploreTrip, 0); + courseMission1 = missionTestHelper.saveMission(courseStamp, 1); + courseMission2 = missionTestHelper.saveMission(courseStamp, 2); + 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( + newMember.getId().toString(), newMember.getRole().name()); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.EXPLORE); + newStamp = stampTestHelper.saveStamp(newTrip, 0); + newMission = missionTestHelper.saveMission(newStamp, 4); + } + + @Nested + @DisplayName("미션 생성 API") + class CreateMission { + private final CreateMissionRequestFixture fixture = new CreateMissionRequestFixture(); + + private ResultActions getResultActions( + String accessToken, Object tripId, Object stampId, CreateMissionRequest request) + throws Exception { + return mockMvc.perform( + post(BASE_MISSION_URL, tripId, stampId) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + // given + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions("", courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), invalidStampId, request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("미션 이름이 null 이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenNameIsNull() throws Exception { + // given + CreateMissionRequest request = fixture.withName(null).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + + @Test + @DisplayName("미션 메모가 null 이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMemoIsNull() throws Exception { + // given + CreateMissionRequest request = fixture.withMemo(null).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + + @Test + @DisplayName("미션 이름이 비어있으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenNameIsBlank() throws Exception { + // given + CreateMissionRequest request = fixture.withName(" ").build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + + @Test + @DisplayName("미션 메모가 비어있으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMemoIsBlank() throws Exception { + // given + CreateMissionRequest request = fixture.withMemo(" ").build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + + @Test + @DisplayName("미션 순서가 1 미만이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenOrderIsLessThanOne() throws Exception { + // given + CreateMissionRequest request = fixture.withMissionOrder(0).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + CreateMissionRequest request = fixture.withMissionOrder(0).build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, deletedTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // given + CreateMissionRequest request = fixture.withMissionOrder(0).build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip.getId(), deletedStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("이미 존재하는 미션 순서일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionOrderAlreadyExists() throws Exception { + // given + CreateMissionRequest request = fixture.withMissionOrder(2).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + System.out.println(resultActions.andReturn().getResponse().getContentAsString()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ORDER_ALREADY_EXISTS + .getStatus() + .value())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(newAccessToken, courseTrip.getId(), newStamp.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // given + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip.getId(), exploreStamp.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, courseStamp.getId(), request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + CreateMissionRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), invalidStampId, request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션을 생성한다.") + void shouldCreateMissionWhenRequestIsValid() throws Exception { + // given + CreateMissionRequest request = fixture.withMissionOrder(3).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.CREATED.value())) + .andExpect(jsonPath("$.data.missionId").isNumber()); + } + } + + @Nested + @DisplayName("미션 수정 API") + class UpdateMission { + private final UpdateMissionRequestFixture fixture = new UpdateMissionRequestFixture(); + + private ResultActions getResultActions( + String accessToken, + Object tripId, + Object stampId, + Object missionId, + UpdateMissionRequest request) + throws Exception { + return mockMvc.perform( + patch(BASE_MISSION_URL + "/{missionId}", tripId, stampId, missionId) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + "", + courseTrip.getId(), + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + invalidTripId, + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + invalidStampId, + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 미션 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionIdTypeMismatch() throws Exception { + // given + String invalidMissionId = "ghi"; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + invalidMissionId, + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + deletedTrip.getId(), + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + deletedStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 미션일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + deletedMission.getId(), + request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + newAccessToken, + courseTrip.getId(), + newStamp.getId(), + newMission.getId(), + request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + exploreStamp.getId(), + exploreMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("미션이 요청한 스탬프에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + exploreMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP + .getStatus() + .value())); + } + + @Test + @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + invalidTripId, + courseStamp.getId(), + courseMission1.getId(), + request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + invalidStampId, + courseMission1.getId(), + request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 미션 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenMissionIdIsInvalid() throws Exception { + // given + Long invalidMissionId = 10000L; + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + invalidMissionId, + request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MissionErrorCode.MISSION_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션 이름을 수정한다.") + void shouldUpdateMissionNameWhenRequestIsValid() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션 메모를 수정한다.") + void shouldUpdateMissionMemoWhenRequestIsValid() throws Exception { + // given + UpdateMissionRequest request = fixture.withName("새로운 미션 메모").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션 이름과 메모를 모두 수정한다.") + void shouldUpdateMissionNameAndMemoWhenRequestIsValid() throws Exception { + // given + UpdateMissionRequest request = + fixture.withName("새로운 미션 메모").withMemo("새로운 미션 메모").build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + courseMission1.getId(), + request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } + + @Nested + @DisplayName("미션 순서 변경 API") + class UpdateMissionOrders { + private final UpdateMissionOrderRequestFixture fixture = + new UpdateMissionOrderRequestFixture(); + + private ResultActions getResultActions( + String accessToken, + Object tripId, + Object stampId, + UpdateMissionOrderRequest request) + throws Exception { + return mockMvc.perform( + put(BASE_MISSION_URL + "/orders", tripId, stampId) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + // given + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions("", courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), invalidStampId, request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, deletedTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // given + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip.getId(), deletedStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 미션이 포함되어 있다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { + // given + List ids = List.of(courseMission2.getId(), deletedMission.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + List ids = List.of(exploreMission2.getId(), exploreMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(newAccessToken, courseTrip.getId(), newStamp.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // given + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip.getId(), exploreStamp.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("미션이 요청한 스탬프에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { + // given + List ids = List.of(exploreMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP + .getStatus() + .value())); + } + + @Test + @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, courseStamp.getId(), request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), invalidStampId, request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("중복된 미션 ID가 들어오면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenDuplicatedIds() throws Exception { + // given + List duplicatedIds = List.of(courseMission1.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(duplicatedIds).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ORDER_IDS_DUPLICATED + .getStatus() + .value())); + } + + @Test + @DisplayName("요청된 미션 ID 개수가 실제 미션 개수와 다르면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenSizeMismatch() throws Exception { + // given + Long invalidMissionId = 10000L; + List ids = List.of(courseMission1.getId(), invalidMissionId); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ORDER_SIZE_MISMATCHED + .getStatus() + .value())); + } + + @Test + @DisplayName("요청된 미션 ID 목록에 존재하지 않는 ID가 포함되어 있으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenIdsContainInvalidMissionId() throws Exception { + // given + Long invalidMissionId = 10000L; + List ids = List.of(courseMission1.getId(), invalidMissionId); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ORDER_IDS_NOT_MATCHED + .getStatus() + .value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션 순서를 변경한다.") + void shouldUpdateMissionOrdersWhenRequestIsValid() throws Exception { + // given + List ids = List.of(courseMission2.getId(), courseMission1.getId()); + UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), courseStamp.getId(), request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } + + @Nested + @DisplayName("미션 삭제 API") + class DeleteMission { + private ResultActions getResultActions( + String accessToken, Object tripId, Object stampId, Object missionId) + throws Exception { + return mockMvc.perform( + delete(BASE_MISSION_URL + "/{missionId}", tripId, stampId, missionId) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken)); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = + getResultActions( + "", exploreTrip.getId(), exploreStamp.getId(), exploreMission1.getId()); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + + // when + ResultActions resultActions = + getResultActions( + accessToken, + invalidTripId, + exploreStamp.getId(), + exploreMission1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + + // when + ResultActions resultActions = + getResultActions( + accessToken, + exploreTrip.getId(), + invalidStampId, + exploreMission1.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 미션 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionIdTypeMismatch() throws Exception { + // given + String invalidMissionId = "ghi"; + + // when + ResultActions resultActions = + getResultActions( + accessToken, + exploreTrip.getId(), + exploreStamp.getId(), + invalidMissionId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + deletedTrip.getId(), + courseStamp.getId(), + courseMission2.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + deletedStamp.getId(), + courseMission2.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 미션일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMissionAlreadyDeleted() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + deletedMission.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // when + ResultActions resultActions = + getResultActions( + newAccessToken, + exploreTrip.getId(), + newStamp.getId(), + newMission.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + exploreStamp.getId(), + exploreMission1.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("미션이 요청한 스탬프에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + courseTrip.getId(), + courseStamp.getId(), + exploreMission1.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP + .getStatus() + .value())); + } + + @Test + @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + // when + ResultActions resultActions = + getResultActions( + accessToken, + invalidTripId, + exploreStamp.getId(), + exploreMission2.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + + // when + ResultActions resultActions = + getResultActions( + accessToken, + exploreTrip.getId(), + invalidStampId, + exploreMission2.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 미션 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenMissionIdIsInvalid() throws Exception { + // given + Long invalidMissionId = 10000L; + + // when + ResultActions resultActions = + getResultActions( + accessToken, + exploreTrip.getId(), + exploreStamp.getId(), + invalidMissionId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MissionErrorCode.MISSION_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션을 삭제한다.") + void shouldDeleteMissionWhenRequestIsValid() throws Exception { + // when + ResultActions resultActions = + getResultActions( + accessToken, + exploreTrip.getId(), + exploreStamp.getId(), + exploreMission2.getId()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } + + @Nested + @DisplayName("미션 목록 조회 API") + class LoadMissionsByStamp { + private ResultActions getResultActions(String accessToken, Object tripId, Object stampId) + throws Exception { + return mockMvc.perform( + get(BASE_MISSION_URL, tripId, stampId) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken)); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = + getResultActions("", exploreTrip.getId(), exploreStamp.getId()); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, exploreStamp.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("PathVariable 스탬프 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampIdTypeMismatch() throws Exception { + // given + String invalidStampId = "def"; + + // when + ResultActions resultActions = + getResultActions(accessToken, exploreTrip.getId(), invalidStampId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // when + ResultActions resultActions = + getResultActions(accessToken, deletedTrip.getId(), courseStamp.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("삭제된 스탬프일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStampAlreadyDeleted() throws Exception { + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), deletedStamp.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // when + ResultActions resultActions = + getResultActions(newAccessToken, exploreTrip.getId(), newStamp.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("스탬프가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { + // when + ResultActions resultActions = + getResultActions(accessToken, courseTrip.getId(), exploreStamp.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StampErrorCode.STAMP_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, exploreStamp.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { + // given + Long invalidStampId = 10000L; + + // when + ResultActions resultActions = + getResultActions(accessToken, exploreTrip.getId(), invalidStampId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 미션 목록을 조회한다.") + void shouldLoadMissionsByStampWhenRequestIsValid() throws Exception { + // when + ResultActions resultActions = + getResultActions(accessToken, exploreTrip.getId(), exploreStamp.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/stamp/application/service/StampServiceTest.java b/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java index e5abb49..243a922 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 @@ -331,7 +331,7 @@ void shouldUpdateStampOrderForCourseTrip() { .willReturn(List.of(courseStamp1, courseStamp2)); // when - stampService.updateStampsOrders(courseTrip, request); + stampService.updateStampOrders(courseTrip, request); // then assertThat(courseStamp2.getStampOrder()).isEqualTo(1); @@ -345,7 +345,7 @@ void shouldThrowExceptionWhenTripIsExplorationType() { UpdateStampOrderRequest request = fixture.buildUpdateOrders(); // when & then - assertThatThrownBy(() -> stampService.updateStampsOrders(exploreTrip, request)) + assertThatThrownBy(() -> stampService.updateStampOrders(exploreTrip, request)) .isInstanceOf(CustomException.class) .hasMessage( StampErrorCode.CANNOT_UPDATE_ORDER_FOR_EXPLORATION_TRIP @@ -360,7 +360,7 @@ void shouldThrowExceptionWhenStampNotFoundById() { fixture.withOrderedStampIds(List.of(1000L, 1001L)).buildUpdateOrders(); // when & then - assertThatThrownBy(() -> stampService.updateStampsOrders(courseTrip, request)) + assertThatThrownBy(() -> stampService.updateStampOrders(courseTrip, request)) .isInstanceOf(CustomException.class) .hasMessage(StampErrorCode.INVALID_STAMP_ID_IN_REQUEST.getMessage()); } @@ -376,7 +376,7 @@ void shouldThrowExceptionWhenStampDoesNotBelongToTrip() { .willReturn(List.of(courseStamp1, courseStamp2)); // when & then - assertThatThrownBy(() -> stampService.updateStampsOrders(newTrip, request)) + assertThatThrownBy(() -> stampService.updateStampOrders(newTrip, request)) .isInstanceOf(CustomException.class) .hasMessage(StampErrorCode.STAMP_NOT_BELONG_TO_TRIP.getMessage()); } @@ -392,7 +392,7 @@ void shouldThrowExceptionWhenStampAlreadyDeleted() { .willReturn(List.of(courseStamp1, courseStamp2)); // when & then - assertThatThrownBy(() -> stampService.updateStampsOrders(courseTrip, request)) + assertThatThrownBy(() -> stampService.updateStampOrders(courseTrip, request)) .isInstanceOf(CustomException.class) .hasMessage(StampErrorCode.STAMP_ALREADY_DELETED.getMessage()); } @@ -410,7 +410,7 @@ void shouldSetAllStampOrdersToZeroWhenCategoryChangesToExplore() { .willReturn(List.of(courseStamp1, courseStamp2)); // when - stampService.updateStampsOrderByTripCategoryChange( + stampService.updateStampOrdersByTripCategoryChange( courseTrip.getId(), TripCategory.EXPLORE); // then @@ -429,7 +429,7 @@ void shouldSetSequentialStampOrdersWhenCategoryChangesToCourse() { .willReturn(List.of(courseStamp1, courseStamp2)); // when - stampService.updateStampsOrderByTripCategoryChange( + stampService.updateStampOrdersByTripCategoryChange( exploreTrip.getId(), TripCategory.COURSE); // then