From 6b011a9018f20931a83f8881b4e1d7b5b515d249 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Thu, 24 Jul 2025 15:45:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=BC=EB=A6=AC=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: DailyGoal, Pomodoro, DailyMission 관련 ErrorCode, Policy, Factory 클래스 추가 * feat: 데일리 목표 생성 기능 구현 (Pomodoro 및 DailyMission 함께 생성) * feat: 데일리 목표 수정 기능 구현 (새로운 DailyMission 추가 및 기존 항목 삭제 지원) * feat: 데일리 목표 삭제 시 관련 Pomodoro, DailyMission 함께 삭제 처리 * feat: 특정 데일리 목표 조회 시 관련 Pomodoro, DailyMission 함께 조회 * feat: 미션 목록 조회 시 관련 Stamp를 fetch join으로 함께 조회하는 쿼리 추가 * feat: Stamp 정보와 함께 미션을 조회하고 검증된 미션 목록을 반환하는 로직 구현 * feat: 미션 에러코드, 검증 메서드 추가 * feat: 코스형 여행에서 현재 진행중인 스탬프를 조회하는 쿼리 및 로직 추가 * test: DailyGoal, Pomodoro, DailyMission Fixture 및 Helper 클래스 작성 * test: DailyGoalController 통합 테스트 작성 * test: DailyGoal, Pomodoro, DailyMission 서비스 단위 테스트 작성 * test: Mission 서비스 단위 테스트 코드 추가 (미션과 스탬프를 함께 조회하고 검증된 미션 목록 반환 테스트) * test: Stamp 서비스 단위 테스트 코드 추가 (코스형 여행에서 현재 진행중인 스탬프 조회 테스트) --- .../application/dto/DailyMissionInfo.java | 10 + .../mission/application/dto/MissionInfo.java | 2 + .../service/DailyMissionService.java | 50 + .../application/service/MissionService.java | 19 + .../domain/error/DailyMissionErrorCode.java | 37 + .../domain/error/MissionErrorCode.java | 1 + .../domain/factory/DailyMissionFactory.java | 14 + .../mission/domain/model/DailyMission.java | 5 + .../mission/domain/model/Mission.java | 4 + .../domain/policy/DailyMissionPolicy.java | 27 + .../mission/domain/policy/MissionPolicy.java | 12 + .../DailyMissionQueryRepository.java | 8 + .../repository/DailyMissionRepository.java | 12 + .../repository/MissionQueryRepository.java | 8 + .../infra/jpa/DailyMissionJpaRepository.java | 11 + .../DailyMissionQueryRepositoryAdapter.java | 28 + .../jpa/DailyMissionRepositoryAdapter.java | 28 + .../jpa/MissionQueryRepositoryAdapter.java | 28 + .../application/dto/PomodoroInfo.java | 9 + .../application/service/PomodoroService.java | 40 + .../domain/error/PomodoroErrorCode.java | 33 + .../pomodoro/domain/model/Pomodoro.java | 5 + .../domain/policy/PomodoroPolicy.java | 15 + .../domain/repository/PomodoroRepository.java | 10 + .../infra/jpa/PomodoroJpaRepository.java | 10 + .../infra/jpa/PomodoroRepositoryAdapter.java | 23 + .../dto/request/CreatePomodoroRequest.java | 6 + .../application/service/StampService.java | 10 + .../studytrip/stamp/domain/model/Stamp.java | 4 + .../repository/StampQueryRepository.java | 3 + .../jpa/StampQueryRepositoryAdapter.java | 15 + .../trip/application/dto/DailyGoalDetail.java | 17 + .../trip/application/dto/DailyGoalInfo.java | 16 + .../application/facade/DailyGoalFacade.java | 138 ++ .../application/service/DailyGoalService.java | 39 + .../trip/domain/error/DailyGoalErrorCode.java | 36 + .../trip/domain/factory/DailyGoalFactory.java | 13 + .../trip/domain/model/DailyGoal.java | 5 + .../trip/domain/policy/DailyGoalPolicy.java | 20 + .../repository/DailyGoalRepository.java | 11 + .../infra/jpa/DailyGoalJpaRepository.java | 6 + .../infra/jpa/DailyGoalRepositoryAdapter.java | 23 + .../controller/DailyGoalController.java | 87 ++ .../dto/request/CreateDailyGoalRequest.java | 14 + .../dto/request/UpdateDailyGoalRequest.java | 8 + .../dto/response/CreateDailyGoalResponse.java | 10 + .../response/LoadDailyGoalDetailResponse.java | 45 + .../service/DailyMissionServiceTest.java | 180 +++ .../service/MissionServiceTest.java | 65 +- .../mission/fixture/DailyMissionFixture.java | 22 + .../helper/DailyMissionTestHelper.java | 20 + .../mission/helper/MissionTestHelper.java | 7 + .../service/PomodoroServiceTest.java | 128 ++ .../pomodoro/fixture/PomodoroFixture.java | 26 + .../pomodoro/helper/PomodoroTestHelper.java | 25 + .../application/service/StampServiceTest.java | 31 + .../stamp/helper/StampTestHelper.java | 7 + .../service/DailyGoalServiceTest.java | 140 ++ .../CreateDailyGoalRequestFixture.java | 25 + .../trip/fixture/DailyGoalFixture.java | 26 + .../UpdateDailyGoalRequestFixture.java | 24 + .../trip/helper/DailyGoalTestHelper.java | 26 + .../DailyGoalControllerIntegrationTest.java | 1304 +++++++++++++++++ 63 files changed, 3029 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/mission/application/dto/DailyMissionInfo.java create mode 100644 src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/error/DailyMissionErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/factory/DailyMissionFactory.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionRepository.java create mode 100644 src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java create mode 100644 src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalDetail.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalInfo.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/facade/DailyGoalFacade.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/error/DailyGoalErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/factory/DailyGoalFactory.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/policy/DailyGoalPolicy.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java create mode 100644 src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/controller/DailyGoalController.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateDailyGoalRequest.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateDailyGoalRequest.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateDailyGoalResponse.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java create mode 100644 src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/mission/fixture/DailyMissionFixture.java create mode 100644 src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java create mode 100644 src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java create mode 100644 src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java create mode 100644 src/test/java/com/ject/studytrip/trip/application/service/DailyGoalServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/CreateDailyGoalRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/DailyGoalFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/UpdateDailyGoalRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/helper/DailyGoalTestHelper.java create mode 100644 src/test/java/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.java diff --git a/src/main/java/com/ject/studytrip/mission/application/dto/DailyMissionInfo.java b/src/main/java/com/ject/studytrip/mission/application/dto/DailyMissionInfo.java new file mode 100644 index 0000000..54c6427 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/application/dto/DailyMissionInfo.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.mission.application.dto; + +import com.ject.studytrip.mission.domain.model.DailyMission; + +public record DailyMissionInfo(Long dailyMissionId, MissionInfo missionInfo) { + public static DailyMissionInfo from(DailyMission dailyMission) { + return new DailyMissionInfo( + dailyMission.getId(), MissionInfo.from(dailyMission.getMission())); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/application/dto/MissionInfo.java b/src/main/java/com/ject/studytrip/mission/application/dto/MissionInfo.java index de0b2dc..83a7602 100644 --- a/src/main/java/com/ject/studytrip/mission/application/dto/MissionInfo.java +++ b/src/main/java/com/ject/studytrip/mission/application/dto/MissionInfo.java @@ -6,6 +6,7 @@ public record MissionInfo( Long missionId, String missionName, + String missionMemo, int missionOrder, boolean completed, String createdAt, @@ -15,6 +16,7 @@ public static MissionInfo from(Mission mission) { return new MissionInfo( mission.getId(), mission.getName(), + mission.getMemo(), mission.getMissionOrder(), mission.isCompleted(), DateUtil.formatDateTime(mission.getCreatedAt()), diff --git a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java new file mode 100644 index 0000000..1969e2d --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java @@ -0,0 +1,50 @@ +package com.ject.studytrip.mission.application.service; + +import com.ject.studytrip.mission.domain.factory.DailyMissionFactory; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.policy.DailyMissionPolicy; +import com.ject.studytrip.mission.domain.repository.DailyMissionQueryRepository; +import com.ject.studytrip.mission.domain.repository.DailyMissionRepository; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DailyMissionService { + private final DailyMissionRepository dailyMissionRepository; + private final DailyMissionQueryRepository dailyMissionQueryRepository; + + public List createDailyMissions(DailyGoal dailyGoal, List missions) { + List dailyMissions = + missions.stream() + .map(mission -> DailyMissionFactory.create(mission, dailyGoal)) + .toList(); + + return dailyMissionRepository.saveAll(dailyMissions); + } + + public void deleteDailyMission(DailyMission dailyMission) { + dailyMission.updateDeletedAt(); + } + + public List getValidDailyMissionsByIds( + Long dailyGoalId, List dailyMissionIds) { + List dailyMissions = dailyMissionRepository.findAllByIdIn(dailyMissionIds); + + DailyMissionPolicy.validateExistAll(dailyMissions, dailyMissionIds); + dailyMissions.forEach( + dailyMission -> { + DailyMissionPolicy.validateBelongsToDailyGoal(dailyMission, dailyGoalId); + DailyMissionPolicy.validateNotDeleted(dailyMission); + }); + + return dailyMissions; + } + + public List getDailyMissionsByDailyGoal(Long dailyGoalId) { + return dailyMissionQueryRepository.findAllByDailyGoalIdFetchJoinMission(dailyGoalId); + } +} 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 23d0c7c..bcc5b4f 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 @@ -6,6 +6,7 @@ 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.MissionQueryRepository; 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; @@ -23,6 +24,7 @@ @RequiredArgsConstructor public class MissionService { private final MissionRepository missionRepository; + private final MissionQueryRepository missionQueryRepository; @Transactional public Mission createMission(Stamp stamp, CreateMissionRequest request) { @@ -98,6 +100,23 @@ public Mission getValidMission(Long stampId, Long missionId) { return mission; } + public List getValidMissionsWithStamp(List missionIds) { + List missions = missionQueryRepository.findAllByIdsInFetchJoinStamp(missionIds); + + MissionPolicy.validateExistAll(missions, missionIds); + missions.forEach( + mission -> { + MissionPolicy.validateNotDeleted(mission); + MissionPolicy.validateCompleted(mission); + }); + + return missions; + } + + public void validateMissionBelongsToStamp(Long stampId, Mission mission) { + MissionPolicy.validateMissionBelongsToStamp(stampId, 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/DailyMissionErrorCode.java b/src/main/java/com/ject/studytrip/mission/domain/error/DailyMissionErrorCode.java new file mode 100644 index 0000000..ebbba03 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/error/DailyMissionErrorCode.java @@ -0,0 +1,37 @@ +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 DailyMissionErrorCode implements ErrorCode { + // 400 + DAILY_MISSION_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 데일리 미션입니다."), + + // 403 + DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL( + HttpStatus.FORBIDDEN, "해당 데일리 미션은 요청한 데일리 목표에 속하지 않습니다."), + + // 404 + DAILY_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/error/MissionErrorCode.java b/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java index 0e4b755..64abde0 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java +++ b/src/main/java/com/ject/studytrip/mission/domain/error/MissionErrorCode.java @@ -12,6 +12,7 @@ public enum MissionErrorCode implements ErrorCode { MISSION_ORDER_IDS_NOT_MATCHED(HttpStatus.BAD_REQUEST, "요청한 미션 ID 목록이 기존 미션 목록과 일치하지 않습니다."), MISSION_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 미션은 이미 삭제되었습니다."), MISSION_ORDER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 미션 순서입니다."), + MISSION_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 미션입니다."), // 403 MISSION_NOT_BELONGS_TO_STAMP(HttpStatus.FORBIDDEN, "해당 미션은 요청한 스탬프에 속하지 않습니다."), diff --git a/src/main/java/com/ject/studytrip/mission/domain/factory/DailyMissionFactory.java b/src/main/java/com/ject/studytrip/mission/domain/factory/DailyMissionFactory.java new file mode 100644 index 0000000..797bf0f --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/factory/DailyMissionFactory.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.mission.domain.factory; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyMissionFactory { + public static DailyMission create(Mission mission, DailyGoal dailyGoal) { + return DailyMission.of(mission, dailyGoal); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java b/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java index efbaeda..03be38d 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java +++ b/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java @@ -3,6 +3,7 @@ import com.ject.studytrip.global.common.entity.BaseTimeEntity; import com.ject.studytrip.trip.domain.model.DailyGoal; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -27,4 +28,8 @@ public class DailyMission extends BaseTimeEntity { public static DailyMission of(Mission mission, DailyGoal dailyGoal) { return DailyMission.builder().mission(mission).dailyGoal(dailyGoal).build(); } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } 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 53efd4b..ec87ba4 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 @@ -58,4 +58,8 @@ public void updateMissionOrder(int missionOrder) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public void updateCompleted() { + this.completed = true; + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java b/src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java new file mode 100644 index 0000000..67890af --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.mission.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.mission.domain.error.DailyMissionErrorCode; +import com.ject.studytrip.mission.domain.model.DailyMission; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyMissionPolicy { + public static void validateExistAll( + List foundDailyMissions, List requestedIds) { + boolean isEquals = foundDailyMissions.size() == requestedIds.size(); + if (!isEquals) throw new CustomException(DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND); + } + + public static void validateBelongsToDailyGoal(DailyMission dailyMission, Long dailyGoalId) { + if (!dailyMission.getDailyGoal().getId().equals(dailyGoalId)) + throw new CustomException(DailyMissionErrorCode.DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL); + } + + public static void validateNotDeleted(DailyMission dailyMission) { + if (dailyMission.getDeletedAt() != null) + throw new CustomException(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java b/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java index d51f197..790f2fd 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java +++ b/src/main/java/com/ject/studytrip/mission/domain/policy/MissionPolicy.java @@ -53,4 +53,16 @@ public static void validateOrderNotDuplicated(boolean exists) { throw new CustomException(MissionErrorCode.MISSION_ORDER_ALREADY_EXISTS); } } + + public static void validateCompleted(Mission mission) { + if (mission.isCompleted()) + throw new CustomException(MissionErrorCode.MISSION_ALREADY_COMPLETED); + } + + public static void validateExistAll(List foundMissions, List requestedIds) { + boolean isEquals = foundMissions.size() == requestedIds.size(); + if (!isEquals) { + throw new CustomException(MissionErrorCode.MISSION_NOT_FOUND); + } + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java new file mode 100644 index 0000000..4cbda68 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.mission.domain.repository; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import java.util.List; + +public interface DailyMissionQueryRepository { + List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId); +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionRepository.java new file mode 100644 index 0000000..9cf4347 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionRepository.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.mission.domain.repository; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import java.util.List; + +public interface DailyMissionRepository { + DailyMission save(DailyMission dailyMission); + + List saveAll(List dailyMissions); + + List findAllByIdIn(List ids); +} diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java new file mode 100644 index 0000000..a9b003a --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.mission.domain.repository; + +import com.ject.studytrip.mission.domain.model.Mission; +import java.util.List; + +public interface MissionQueryRepository { + List findAllByIdsInFetchJoinStamp(List ids); +} diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionJpaRepository.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionJpaRepository.java new file mode 100644 index 0000000..be9653e --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionJpaRepository.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.mission.infra.jpa; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DailyMissionJpaRepository extends JpaRepository { + List findAllByIdIn(List ids); + + List findAllByDailyGoalIdAndDeletedAtIsNull(Long dailyGoalId); +} diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java new file mode 100644 index 0000000..d6d5bff --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.mission.infra.jpa; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.QDailyMission; +import com.ject.studytrip.mission.domain.model.QMission; +import com.ject.studytrip.mission.domain.repository.DailyMissionQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class DailyMissionQueryRepositoryAdapter implements DailyMissionQueryRepository { + private final JPAQueryFactory queryFactory; + private final QDailyMission dailyMission = QDailyMission.dailyMission; + private final QMission mission = QMission.mission; + + @Override + public List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId) { + return queryFactory + .selectFrom(dailyMission) + .join(dailyMission.mission, mission) + .fetchJoin() + .where(dailyMission.dailyGoal.id.eq(dailyGoalId), dailyMission.deletedAt.isNull()) + .fetch(); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionRepositoryAdapter.java new file mode 100644 index 0000000..3c9b3d8 --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionRepositoryAdapter.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.mission.infra.jpa; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.repository.DailyMissionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class DailyMissionRepositoryAdapter implements DailyMissionRepository { + private final DailyMissionJpaRepository dailyMissionJpaRepository; + + @Override + public DailyMission save(DailyMission dailyMission) { + return dailyMissionJpaRepository.save(dailyMission); + } + + @Override + public List saveAll(List dailyMissions) { + return dailyMissionJpaRepository.saveAll(dailyMissions); + } + + @Override + public List findAllByIdIn(List ids) { + return dailyMissionJpaRepository.findAllByIdIn(ids); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java new file mode 100644 index 0000000..860809b --- /dev/null +++ b/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.mission.infra.jpa; + +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.model.QMission; +import com.ject.studytrip.mission.domain.repository.MissionQueryRepository; +import com.ject.studytrip.stamp.domain.model.QStamp; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MissionQueryRepositoryAdapter implements MissionQueryRepository { + private final JPAQueryFactory queryFactory; + private final QMission mission = QMission.mission; + private final QStamp stamp = QStamp.stamp; + + @Override + public List findAllByIdsInFetchJoinStamp(List ids) { + return queryFactory + .selectFrom(mission) + .join(mission.stamp, stamp) + .fetchJoin() + .where(mission.id.in(ids)) + .fetch(); + } +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java b/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java new file mode 100644 index 0000000..5d0fb23 --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.pomodoro.application.dto; + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; + +public record PomodoroInfo(Long pomodoroId, int focusDurationInMinute) { + public static PomodoroInfo from(Pomodoro pomodoro) { + return new PomodoroInfo(pomodoro.getId(), pomodoro.getFocusDurationInSeconds() / 60); + } +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java new file mode 100644 index 0000000..16fae6d --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java @@ -0,0 +1,40 @@ +package com.ject.studytrip.pomodoro.application.service; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; +import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.domain.policy.PomodoroPolicy; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PomodoroService { + private final PomodoroRepository pomodoroRepository; + + public Pomodoro createPomodoro(DailyGoal dailyGoal, CreatePomodoroRequest request) { + int focusDurationInSeconds = request.focusDurationInMinute() * 60; + Pomodoro pomodoro = PomodoroFactory.create(dailyGoal, focusDurationInSeconds, 1, 0); + return pomodoroRepository.save(pomodoro); + } + + public void deletePomodoro(Pomodoro pomodoro) { + pomodoro.updateDeletedAt(); + } + + public Pomodoro getValidPomodoroByDailyGoal(Long dailyGoalId) { + Pomodoro pomodoro = + pomodoroRepository + .findByDailyGoalId(dailyGoalId) + .orElseThrow( + () -> new CustomException(PomodoroErrorCode.POMODORO_NOT_FOUND)); + + PomodoroPolicy.validateNotDeleted(pomodoro); + + return pomodoro; + } +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java b/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java new file mode 100644 index 0000000..b9fe5c6 --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java @@ -0,0 +1,33 @@ +package com.ject.studytrip.pomodoro.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum PomodoroErrorCode implements ErrorCode { + // 400 + POMODORO_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 뽀모도로입니다."), + + // 404 + POMODORO_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/pomodoro/domain/model/Pomodoro.java b/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java index 3c7af64..75847ad 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java @@ -3,6 +3,7 @@ import com.ject.studytrip.global.common.entity.BaseTimeEntity; import com.ject.studytrip.trip.domain.model.DailyGoal; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -40,4 +41,8 @@ public static Pomodoro of( .totalFocusTimeInSeconds(0) .build(); } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java b/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java new file mode 100644 index 0000000..2d3a161 --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java @@ -0,0 +1,15 @@ +package com.ject.studytrip.pomodoro.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PomodoroPolicy { + public static void validateNotDeleted(Pomodoro pomodoro) { + if (pomodoro.getDeletedAt() != null) + throw new CustomException(PomodoroErrorCode.POMODORO_ALREADY_DELETED); + } +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java new file mode 100644 index 0000000..a1ba2eb --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.pomodoro.domain.repository; + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import java.util.Optional; + +public interface PomodoroRepository { + Pomodoro save(Pomodoro pomodoro); + + Optional findByDailyGoalId(Long dailyGoalId); +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java new file mode 100644 index 0000000..ad47237 --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.pomodoro.infra.jpa; + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PomodoroJpaRepository extends JpaRepository { + + Optional findByDailyGoalId(Long dailyGoalId); +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java new file mode 100644 index 0000000..d3371b0 --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.pomodoro.infra.jpa; + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PomodoroRepositoryAdapter implements PomodoroRepository { + private final PomodoroJpaRepository pomodoroJpaRepository; + + @Override + public Pomodoro save(Pomodoro pomodoro) { + return pomodoroJpaRepository.save(pomodoro); + } + + @Override + public Optional findByDailyGoalId(Long dailyGoalId) { + return pomodoroJpaRepository.findByDailyGoalId(dailyGoalId); + } +} diff --git a/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java b/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java new file mode 100644 index 0000000..debccae --- /dev/null +++ b/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.pomodoro.presentation.dto.request; + +import jakarta.validation.constraints.Min; + +public record CreatePomodoroRequest( + @Min(value = 1, message = "뽀모도로 최소 집중 시간은 1분입니다.") int focusDurationInMinute) {} 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 d193e7e..a2217b9 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 @@ -131,6 +131,16 @@ public Stamp getValidStamp(Long tripId, Long stampId) { return stamp; } + public Stamp getFirstInCompleteStampForCourseTrip(Long tripId) { + return stampQueryRepository + .findFirstIncompleteStampByTripId(tripId) + .orElseThrow(() -> new CustomException(StampErrorCode.STAMP_NOT_FOUND)); + } + + public void validateStampBelongsToTrip(Long tripId, Stamp stamp) { + StampPolicy.validateStampBelongsToTrip(tripId, stamp); + } + private void shiftStampOrdersAfterDeleted(Long tripId, int deletedStampOrder) { List affectedStamps = stampQueryRepository.findStampsToShiftAfterOrder(tripId, deletedStampOrder); diff --git a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java index be747f3..082799f 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java @@ -54,6 +54,10 @@ public void updateStampOrder(int newOrder) { this.stampOrder = newOrder; } + public void updateCompleted() { + this.completed = true; + } + public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java index ff7e86b..0d59369 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampQueryRepository.java @@ -2,7 +2,10 @@ import com.ject.studytrip.stamp.domain.model.Stamp; import java.util.List; +import java.util.Optional; public interface StampQueryRepository { List findStampsToShiftAfterOrder(Long tripId, int deletedOrder); + + Optional findFirstIncompleteStampByTripId(Long tripId); } diff --git a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java index 472e055..7fd7518 100644 --- a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampQueryRepositoryAdapter.java @@ -5,6 +5,7 @@ import com.ject.studytrip.stamp.domain.repository.StampQueryRepository; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -25,4 +26,18 @@ public List findStampsToShiftAfterOrder(Long tripId, int deletedOrder) { .orderBy(stamp.stampOrder.asc()) .fetch(); } + + // 완료되지 않은 스탬프 중 가장 첫번째 스탬프 조회 + @Override + public Optional findFirstIncompleteStampByTripId(Long tripId) { + return Optional.ofNullable( + queryFactory + .selectFrom(stamp) + .where( + stamp.trip.id.eq(tripId), + stamp.completed.isFalse(), + stamp.deletedAt.isNull()) + .orderBy(stamp.stampOrder.asc()) + .fetchFirst()); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalDetail.java b/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalDetail.java new file mode 100644 index 0000000..fc66f22 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalDetail.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.mission.application.dto.DailyMissionInfo; +import com.ject.studytrip.pomodoro.application.dto.PomodoroInfo; +import java.util.List; + +public record DailyGoalDetail( + DailyGoalInfo dailyGoalInfo, + PomodoroInfo pomodoroInfo, + List dailyMissionInfos) { + public static DailyGoalDetail from( + DailyGoalInfo dailyGoalInfo, + PomodoroInfo pomodoroInfo, + List dailyMissionInfos) { + return new DailyGoalDetail(dailyGoalInfo, pomodoroInfo, dailyMissionInfos); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalInfo.java new file mode 100644 index 0000000..3679830 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/DailyGoalInfo.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.trip.domain.model.DailyGoal; + +public record DailyGoalInfo( + Long dailyGoalId, boolean completed, String createdAt, String updatedAt, String deletedAt) { + public static DailyGoalInfo from(DailyGoal dailyGoal) { + return new DailyGoalInfo( + dailyGoal.getId(), + dailyGoal.isCompleted(), + DateUtil.formatDateTime(dailyGoal.getCreatedAt()), + DateUtil.formatDateTime(dailyGoal.getUpdatedAt()), + DateUtil.formatDateTime(dailyGoal.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/facade/DailyGoalFacade.java b/src/main/java/com/ject/studytrip/trip/application/facade/DailyGoalFacade.java new file mode 100644 index 0000000..5f0a054 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/facade/DailyGoalFacade.java @@ -0,0 +1,138 @@ +package com.ject.studytrip.trip.application.facade; + +import com.ject.studytrip.member.application.service.MemberService; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.mission.application.dto.DailyMissionInfo; +import com.ject.studytrip.mission.application.service.DailyMissionService; +import com.ject.studytrip.mission.application.service.MissionService; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.pomodoro.application.dto.PomodoroInfo; +import com.ject.studytrip.pomodoro.application.service.PomodoroService; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.stamp.application.service.StampService; +import com.ject.studytrip.trip.application.dto.DailyGoalDetail; +import com.ject.studytrip.trip.application.dto.DailyGoalInfo; +import com.ject.studytrip.trip.application.service.DailyGoalService; +import com.ject.studytrip.trip.application.service.TripService; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.presentation.dto.request.CreateDailyGoalRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateDailyGoalRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DailyGoalFacade { + private final MemberService memberService; + private final TripService tripService; + private final StampService stampService; + private final MissionService missionService; + private final DailyGoalService dailyGoalService; + private final PomodoroService pomodoroService; + private final DailyMissionService dailyMissionService; + + @Transactional + public DailyGoalInfo createDailyGoal( + Long memberId, Long tripId, CreateDailyGoalRequest request) { + Trip trip = getValidTripOwnedByMember(memberId, tripId); + + DailyGoal dailyGoal = dailyGoalService.createDailyGoal(trip); + + List missions = getValidMissionsByTripCategory(trip, request.missionIds()); + dailyMissionService.createDailyMissions(dailyGoal, missions); + + pomodoroService.createPomodoro(dailyGoal, request.pomodoro()); + + return DailyGoalInfo.from(dailyGoal); + } + + @Transactional + public void updateDailyGoal( + Long memberId, Long tripId, Long dailyGoalId, UpdateDailyGoalRequest request) { + Trip trip = getValidTripOwnedByMember(memberId, tripId); + + DailyGoal dailyGoal = dailyGoalService.getValidDailyGoal(trip.getId(), dailyGoalId); + + // 삭제할 데일리 미션이 있을 경우 + if (request.deleteDailyMissionIds() != null && !request.deleteDailyMissionIds().isEmpty()) { + List deleteDailyMissions = + dailyMissionService.getValidDailyMissionsByIds( + dailyGoal.getId(), request.deleteDailyMissionIds()); + deleteDailyMissions.forEach(dailyMissionService::deleteDailyMission); + } + + // 새로 추가할 미션이 있을 경우 + if (request.addMissionIds() != null && !request.addMissionIds().isEmpty()) { + List addMissions = + getValidMissionsByTripCategory(trip, request.addMissionIds()); + dailyMissionService.createDailyMissions(dailyGoal, addMissions); + } + } + + @Transactional + public void deleteDailyGoal(Long memberId, Long tripId, Long dailyGoalId) { + Trip trip = getValidTripOwnedByMember(memberId, tripId); + + DailyGoal dailyGoal = dailyGoalService.getValidDailyGoal(trip.getId(), dailyGoalId); + + // 뽀모도로 삭제 + Pomodoro pomodoro = pomodoroService.getValidPomodoroByDailyGoal(dailyGoal.getId()); + pomodoroService.deletePomodoro(pomodoro); + + // 데일리 미션 삭제 + List dailyMissions = + dailyMissionService.getDailyMissionsByDailyGoal(dailyGoal.getId()); + for (DailyMission dailyMission : dailyMissions) { + dailyMissionService.deleteDailyMission(dailyMission); + } + + // 데일리 목표 삭제 + dailyGoalService.deleteDailyGoal(dailyGoal); + } + + public DailyGoalDetail getDailyGoal(Long memberId, Long tripId, Long dailyGoalId) { + Trip trip = getValidTripOwnedByMember(memberId, tripId); + + DailyGoal dailyGoal = dailyGoalService.getValidDailyGoal(trip.getId(), dailyGoalId); + + Pomodoro pomodoro = pomodoroService.getValidPomodoroByDailyGoal(dailyGoal.getId()); + List dailyMissions = + dailyMissionService.getDailyMissionsByDailyGoal(dailyGoal.getId()); + + return DailyGoalDetail.from( + DailyGoalInfo.from(dailyGoal), + PomodoroInfo.from(pomodoro), + dailyMissions.stream().map(DailyMissionInfo::from).toList()); + } + + private Trip getValidTripOwnedByMember(Long memberId, Long tripId) { + Member member = memberService.getMember(memberId); + + return tripService.getValidTrip(member.getId(), tripId); + } + + private List getValidMissionsByTripCategory(Trip trip, List missionIds) { + List missions = missionService.getValidMissionsWithStamp(missionIds); + + for (Mission mission : missions) { + stampService.validateStampBelongsToTrip(trip.getId(), mission.getStamp()); + } + + // 코스형 여행일 경우, 현재 진행중인 스탬프를 조회하고 요청한 미션들이 해당 스탬프에 속해 있는 미션들인지 검증 + if (trip.getCategory() == TripCategory.COURSE) { + Long currentStampId = + stampService.getFirstInCompleteStampForCourseTrip(trip.getId()).getId(); + + for (Mission mission : missions) { + missionService.validateMissionBelongsToStamp(currentStampId, mission); + } + } + + return missions; + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java b/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java new file mode 100644 index 0000000..25dc9ba --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java @@ -0,0 +1,39 @@ +package com.ject.studytrip.trip.application.service; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.DailyGoalErrorCode; +import com.ject.studytrip.trip.domain.factory.DailyGoalFactory; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.policy.DailyGoalPolicy; +import com.ject.studytrip.trip.domain.repository.DailyGoalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DailyGoalService { + public final DailyGoalRepository dailyGoalRepository; + + public DailyGoal createDailyGoal(Trip trip) { + DailyGoal dailyGoal = DailyGoalFactory.create(trip); + return dailyGoalRepository.save(dailyGoal); + } + + public void deleteDailyGoal(DailyGoal dailyGoal) { + dailyGoal.updateDeletedAt(); + } + + public DailyGoal getValidDailyGoal(Long tripId, Long dailyGoalId) { + DailyGoal dailyGoal = + dailyGoalRepository + .findById(dailyGoalId) + .orElseThrow( + () -> new CustomException(DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND)); + + DailyGoalPolicy.validateBelongsToTrip(dailyGoal, tripId); + DailyGoalPolicy.validateNotDeleted(dailyGoal); + + return dailyGoal; + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/error/DailyGoalErrorCode.java b/src/main/java/com/ject/studytrip/trip/domain/error/DailyGoalErrorCode.java new file mode 100644 index 0000000..76f0ba6 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/error/DailyGoalErrorCode.java @@ -0,0 +1,36 @@ +package com.ject.studytrip.trip.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum DailyGoalErrorCode implements ErrorCode { + // 400 + DAILY_GOAL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 데일리 목표입니다."), + + // 403 + DAILY_GOAL_NOT_BELONG_TO_TRIP(HttpStatus.FORBIDDEN, "해당 데일리 목표는 요청한 여행에 속하지 않습니다."), + + // 404 + DAILY_GOAL_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/trip/domain/factory/DailyGoalFactory.java b/src/main/java/com/ject/studytrip/trip/domain/factory/DailyGoalFactory.java new file mode 100644 index 0000000..12c8960 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/factory/DailyGoalFactory.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.trip.domain.factory; + +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyGoalFactory { + public static DailyGoal create(Trip trip) { + return DailyGoal.of(trip); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java b/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java index e7ef978..863d855 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java @@ -2,6 +2,7 @@ import com.ject.studytrip.global.common.entity.BaseTimeEntity; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -24,4 +25,8 @@ public class DailyGoal extends BaseTimeEntity { public static DailyGoal of(Trip trip) { return DailyGoal.builder().trip(trip).completed(false).build(); } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/policy/DailyGoalPolicy.java b/src/main/java/com/ject/studytrip/trip/domain/policy/DailyGoalPolicy.java new file mode 100644 index 0000000..f68e5a2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/policy/DailyGoalPolicy.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.trip.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.DailyGoalErrorCode; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DailyGoalPolicy { + public static void validateBelongsToTrip(DailyGoal dailyGoal, Long tripId) { + if (!dailyGoal.getTrip().getId().equals(tripId)) + throw new CustomException(DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP); + } + + public static void validateNotDeleted(DailyGoal dailyGoal) { + if (dailyGoal.getDeletedAt() != null) + throw new CustomException(DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java new file mode 100644 index 0000000..9d8564f --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.trip.domain.repository; + +import com.ject.studytrip.trip.domain.model.DailyGoal; +import java.util.Optional; + +public interface DailyGoalRepository { + + DailyGoal save(DailyGoal dailyGoal); + + Optional findById(Long id); +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java new file mode 100644 index 0000000..c2e1538 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DailyGoalJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java new file mode 100644 index 0000000..09f07e3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.repository.DailyGoalRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class DailyGoalRepositoryAdapter implements DailyGoalRepository { + private final DailyGoalJpaRepository dailyGoalJpaRepository; + + @Override + public DailyGoal save(DailyGoal dailyGoal) { + return dailyGoalJpaRepository.save(dailyGoal); + } + + @Override + public Optional findById(Long id) { + return dailyGoalJpaRepository.findById(id); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/controller/DailyGoalController.java b/src/main/java/com/ject/studytrip/trip/presentation/controller/DailyGoalController.java new file mode 100644 index 0000000..57ff150 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/controller/DailyGoalController.java @@ -0,0 +1,87 @@ +package com.ject.studytrip.trip.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.trip.application.dto.DailyGoalDetail; +import com.ject.studytrip.trip.application.dto.DailyGoalInfo; +import com.ject.studytrip.trip.application.facade.DailyGoalFacade; +import com.ject.studytrip.trip.presentation.dto.request.CreateDailyGoalRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateDailyGoalRequest; +import com.ject.studytrip.trip.presentation.dto.response.CreateDailyGoalResponse; +import com.ject.studytrip.trip.presentation.dto.response.LoadDailyGoalDetailResponse; +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 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 = "DailyGoal", description = "데일리 목표 API") +@RestController +@RequiredArgsConstructor +@Validated +public class DailyGoalController { + private final DailyGoalFacade dailyGoalFacade; + + @Operation(summary = "데일리 목표 생성", description = "데일리 목표를 생성하는 API 입니다.") + @PostMapping("/api/trips/{tripId}/daily-goals") + public ResponseEntity createDailyGoal( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @RequestBody @Valid CreateDailyGoalRequest request) { + DailyGoalInfo result = + dailyGoalFacade.createDailyGoal(Long.valueOf(memberId), tripId, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + StandardResponse.success( + HttpStatus.CREATED.value(), CreateDailyGoalResponse.of(result))); + } + + @Operation(summary = "데일리 목표 수정", description = "데일리 목표를 수정하는 API 입니다.") + @PatchMapping("/api/trips/{tripId}/daily-goals/{dailyGoalId}") + public ResponseEntity updateDailyGoal( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") Long dailyGoalId, + @RequestBody UpdateDailyGoalRequest request) { + dailyGoalFacade.updateDailyGoal(Long.valueOf(memberId), tripId, dailyGoalId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "데일리 목표 삭제", description = "데일리 목표를 삭제하는 API 입니다.") + @DeleteMapping("/api/trips/{tripId}/daily-goals/{dailyGoalId}") + public ResponseEntity deleteDailyGoal( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") Long dailyGoalId) { + dailyGoalFacade.deleteDailyGoal(Long.valueOf(memberId), tripId, dailyGoalId); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "특정 데일리 목표 조회", description = "특정 데일리 목표를 조회하는 API 입니다.") + @GetMapping("/api/trips/{tripId}/daily-goals/{dailyGoalId}") + public ResponseEntity loadDailyGoal( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") Long dailyGoalId) { + DailyGoalDetail result = + dailyGoalFacade.getDailyGoal(Long.valueOf(memberId), tripId, dailyGoalId); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadDailyGoalDetailResponse.of( + result.dailyGoalInfo(), + result.pomodoroInfo(), + result.dailyMissionInfos()))); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateDailyGoalRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateDailyGoalRequest.java new file mode 100644 index 0000000..454e947 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateDailyGoalRequest.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateDailyGoalRequest( + @Schema(name = "뽀모도로") @Valid @NotNull(message = "뽀모도로 정보는 필수 요청 값입니다.") + CreatePomodoroRequest pomodoro, + @Schema(name = "수행할 미션 ID 목록") @NotEmpty(message = "수행할 미션 목록은 필수 요청 값입니다.") + List missionIds) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateDailyGoalRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateDailyGoalRequest.java new file mode 100644 index 0000000..fd7dd87 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateDailyGoalRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record UpdateDailyGoalRequest( + @Schema(name = "삭제할 데일리 미션 ID 목록") List deleteDailyMissionIds, + @Schema(name = "추가할 미션 ID 목록") List addMissionIds) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateDailyGoalResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateDailyGoalResponse.java new file mode 100644 index 0000000..ec3b2e1 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateDailyGoalResponse.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.trip.application.dto.DailyGoalInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateDailyGoalResponse(@Schema(name = "데일리 목표 ID") Long dailyGoalId) { + public static CreateDailyGoalResponse of(DailyGoalInfo dailyGoalInfo) { + return new CreateDailyGoalResponse(dailyGoalInfo.dailyGoalId()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java new file mode 100644 index 0000000..23f6ee5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java @@ -0,0 +1,45 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.mission.application.dto.DailyMissionInfo; +import com.ject.studytrip.pomodoro.application.dto.PomodoroInfo; +import com.ject.studytrip.trip.application.dto.DailyGoalInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record LoadDailyGoalDetailResponse( + @Schema(name = "데일리 목표 ID") Long dailyGoalId, + @Schema(name = "데일리 목표 완료 여부") boolean completed, + @Schema(name = "뽀모도로 정보") DailyGoalPomodoroResponse pomodoro, + @Schema(name = "수행할 데일리 미션 목록") List dailyMissions) { + + public static LoadDailyGoalDetailResponse of( + DailyGoalInfo dailyGoalInfo, + PomodoroInfo pomodoroInfo, + List dailyMissionInfos) { + return new LoadDailyGoalDetailResponse( + dailyGoalInfo.dailyGoalId(), + dailyGoalInfo.completed(), + DailyGoalPomodoroResponse.of(pomodoroInfo), + dailyMissionInfos.stream().map(DailyGoalMissionResponse::of).toList()); + } + + public record DailyGoalPomodoroResponse( + @Schema(name = "뽀모도로 ID") Long pomodoroId, + @Schema(name = "뽀모도로 집중 시간(분)") int focusDurationInMinute) { + public static DailyGoalPomodoroResponse of(PomodoroInfo info) { + return new DailyGoalPomodoroResponse(info.pomodoroId(), info.focusDurationInMinute()); + } + } + + public record DailyGoalMissionResponse( + @Schema(name = "데일리 미션 ID") Long dailyMissionId, + @Schema(name = "미션 이름") String missionName, + @Schema(name = "미션 메모") String missionMemo) { + public static DailyGoalMissionResponse of(DailyMissionInfo info) { + return new DailyGoalMissionResponse( + info.dailyMissionId(), + info.missionInfo().missionName(), + info.missionInfo().missionMemo()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java new file mode 100644 index 0000000..1e17026 --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java @@ -0,0 +1,180 @@ +package com.ject.studytrip.mission.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.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.DailyMissionErrorCode; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.repository.DailyMissionQueryRepository; +import com.ject.studytrip.mission.domain.repository.DailyMissionRepository; +import com.ject.studytrip.mission.fixture.DailyMissionFixture; +import com.ject.studytrip.mission.fixture.MissionFixture; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.fixture.StampFixture; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.DailyGoalFixture; +import com.ject.studytrip.trip.fixture.TripFixture; +import java.util.List; +import org.assertj.core.api.Assertions; +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; + +@DisplayName("DailyMissionService 단위 테스트") +public class DailyMissionServiceTest extends BaseUnitTest { + + @InjectMocks private DailyMissionService dailyMissionService; + @Mock private DailyMissionRepository dailyMissionRepository; + @Mock private DailyMissionQueryRepository dailyMissionQueryRepository; + + private DailyGoal dailyGoal; + private Mission mission; + private DailyMission dailyMission; + + @BeforeEach + void setUp() { + Member member = MemberFixture.createMemberFromKakaoWithId(1L); + Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + Stamp stamp = StampFixture.createStampWithId(1L, trip, 1); + mission = MissionFixture.createMissionWithId(1L, stamp, 1); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); + dailyMission = DailyMissionFixture.createDailyMissionWithId(1L, mission, dailyGoal); + } + + @Nested + @DisplayName("데일리 미션을 생성한다") + class CreateDailyMission { + + @Test + @DisplayName("미션 리스트로 데일리 미션을 생성하여 저장하고 반환한다") + void shouldCreateDailyMissions() { + // given + List missions = List.of(mission); + given(dailyMissionRepository.saveAll(any())).willReturn(List.of(dailyMission)); + + // when + List result = + dailyMissionService.createDailyMissions(dailyGoal, missions); + + // then + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getDailyGoal().getId()).isEqualTo(dailyGoal.getId()); + } + } + + @Nested + @DisplayName("데일리 미션을 삭제한다") + class DeleteDailyMission { + + @Test + @DisplayName("삭제 시 deletedAt을 현재 시각으로 설정한다") + void shouldDeleteDailyMission() { + // when + dailyMissionService.deleteDailyMission(dailyMission); + + // then + assertThat(dailyMission.getDeletedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("데일리 미션 목록 조회") + class ListDailyMissions { + + @Test + @DisplayName("ID 리스트로 유효한 데일리 미션을 조회해 반환한다") + void shouldGetDailyMissionsByIds() { + // given + List ids = List.of(dailyMission.getId()); + List dailyMissions = List.of(dailyMission); + given(dailyMissionRepository.findAllByIdIn(ids)).willReturn(dailyMissions); + + // when + List result = + dailyMissionService.getValidDailyMissionsByIds(dailyGoal.getId(), ids); + + // then + assertThat(result.isEmpty()).isFalse(); + } + + @Test + @DisplayName("요청한 ID 개수와 조회된 데일리 미션 개수가 다르면 예외가 발생한다") + void shouldThrowExceptionWhenSomeDailyMissionsDoNotExist() { + // given + List ids = List.of(1L, 2L); + given(dailyMissionRepository.findAllByIdIn(ids)).willReturn(List.of(dailyMission)); + + // when & then + assertThatThrownBy( + () -> + dailyMissionService.getValidDailyMissionsByIds( + dailyGoal.getId(), ids)) + .isInstanceOf(CustomException.class) + .hasMessage(DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("데일리 미션이 요청한 데일리 목표에 속하지 않으면 예외를 던진다") + void shouldThrowExceptionWhenDailyMissionDoesNotBelongToDailyGoal() { + // given + DailyGoal otherGoal = DailyGoalFixture.createDailyGoalWithId(999L, dailyGoal.getTrip()); + List ids = List.of(dailyMission.getId()); + given(dailyMissionRepository.findAllByIdIn(ids)).willReturn(List.of(dailyMission)); + + // when & then + assertThatThrownBy( + () -> + dailyMissionService.getValidDailyMissionsByIds( + otherGoal.getId(), ids)) + .isInstanceOf(CustomException.class) + .hasMessage( + DailyMissionErrorCode.DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL + .getMessage()); + } + + @Test + @DisplayName("데일리 미션이 이미 삭제된 경우 예외가 발생한다") + void shouldThrowExceptionWhenDailyMissionIsDeleted() { + // given + dailyMission.updateDeletedAt(); // deleted + given(dailyMissionRepository.findAllByIdIn(List.of(1L))) + .willReturn(List.of(dailyMission)); + + // when & then + Assertions.assertThatThrownBy( + () -> + dailyMissionService.getValidDailyMissionsByIds( + dailyGoal.getId(), List.of(dailyMission.getId()))) + .isInstanceOf(CustomException.class) + .hasMessage(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("데일리 목표 ID로 데일리 미션 목록을 반환한다") + void shouldGetDailyMissionsByDailyGoalId() { + // given + Long goalId = dailyGoal.getId(); + List missions = List.of(dailyMission); + given(dailyMissionQueryRepository.findAllByDailyGoalIdFetchJoinMission(goalId)) + .willReturn(missions); + + // when + List result = dailyMissionService.getDailyMissionsByDailyGoal(goalId); + + // then + assertThat(result.isEmpty()).isFalse(); + } + } +} diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java index 3f349b5..36a4fbe 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java @@ -11,6 +11,7 @@ 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.MissionQueryRepository; import com.ject.studytrip.mission.domain.repository.MissionRepository; import com.ject.studytrip.mission.fixture.CreateMissionRequestFixture; import com.ject.studytrip.mission.fixture.MissionFixture; @@ -41,7 +42,10 @@ class MissionServiceTest extends BaseUnitTest { @InjectMocks private MissionService missionService; @Mock private MissionRepository missionRepository; + @Mock private MissionQueryRepository missionQueryRepository; + private Trip courseTrip; + private Trip exploreTrip; private Stamp courseStamp; private Stamp exploreStamp; private Mission courseMission; @@ -51,8 +55,8 @@ class MissionServiceTest extends BaseUnitTest { @BeforeEach void setUp() { Member member = MemberFixture.createMemberFromKakao(); - Trip courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - Trip exploreTrip = TripFixture.createTripWithId(2L, member, TripCategory.EXPLORE); + courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + 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); @@ -454,5 +458,62 @@ void shouldReturnMissionWhenValid() { // then assertThat(result).isEqualTo(exploreMission1); } + + @Test + @DisplayName("유효한 미션 ID들로 요청 시 검증을 통과하고 미션 리스트를 반환한다") + void shouldReturnValidMissions() { + // given + List missionIds = List.of(courseMission.getId()); + given(missionQueryRepository.findAllByIdsInFetchJoinStamp(missionIds)) + .willReturn(List.of(courseMission)); + + // when + List result = missionService.getValidMissionsWithStamp(missionIds); + + // then + assertThat(result).containsExactly(courseMission); + } + + @Test + @DisplayName("요청한 ID 개수와 조회된 미션 개수가 다르면 예외가 발생한다") + void shouldThrowExceptionWhenSomeMissionsDoNotExist() { + // given + List missionIds = List.of(1L, 2L); + given(missionQueryRepository.findAllByIdsInFetchJoinStamp(missionIds)) + .willReturn(List.of(courseMission)); + + // when & then + assertThatThrownBy(() -> missionService.getValidMissionsWithStamp(missionIds)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("이미 삭제된 미션이 포함되어 있을 경우 예외가 발생한다") + void shouldThrowExceptionWhenAnyMissionIsDeleted() { + // given + courseMission.updateDeletedAt(); // deleted + given(missionQueryRepository.findAllByIdsInFetchJoinStamp(any())) + .willReturn(List.of(courseMission)); + + // when & then + assertThatThrownBy(() -> missionService.getValidMissionsWithStamp(List.of(1L))) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("이미 완료된 미션이 포함되어 있을 경우 예외가 발생한다") + void shouldThrowExceptionWhenAnyMissionIsCompleted() { + // given + courseMission.updateCompleted(); + given(missionQueryRepository.findAllByIdsInFetchJoinStamp(any())) + .willReturn(List.of(courseMission)); + + // when & then + assertThatThrownBy(() -> missionService.getValidMissionsWithStamp(List.of(1L))) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_COMPLETED.getMessage()); + } } } diff --git a/src/test/java/com/ject/studytrip/mission/fixture/DailyMissionFixture.java b/src/test/java/com/ject/studytrip/mission/fixture/DailyMissionFixture.java new file mode 100644 index 0000000..24aaf9a --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/fixture/DailyMissionFixture.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.mission.fixture; + +import com.ject.studytrip.mission.domain.factory.DailyMissionFactory; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.test.util.ReflectionTestUtils; + +public class DailyMissionFixture { + + public static DailyMission createDailyMission(Mission mission, DailyGoal dailyGoal) { + return DailyMissionFactory.create(mission, dailyGoal); + } + + public static DailyMission createDailyMissionWithId( + Long id, Mission mission, DailyGoal dailyGoal) { + DailyMission dailyMission = DailyMissionFactory.create(mission, dailyGoal); + ReflectionTestUtils.setField(dailyMission, "id", id); + + return dailyMission; + } +} diff --git a/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java b/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java new file mode 100644 index 0000000..ab3597b --- /dev/null +++ b/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.mission.helper; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.domain.repository.DailyMissionRepository; +import com.ject.studytrip.mission.fixture.DailyMissionFixture; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class DailyMissionTestHelper { + + @Autowired private DailyMissionRepository dailyMissionRepository; + + public DailyMission saveDailyMission(Mission mission, DailyGoal dailyGoal) { + DailyMission dailyMission = DailyMissionFixture.createDailyMission(mission, dailyGoal); + return dailyMissionRepository.save(dailyMission); + } +} diff --git a/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java b/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java index 95e8536..e8a466d 100644 --- a/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java +++ b/src/test/java/com/ject/studytrip/mission/helper/MissionTestHelper.java @@ -23,4 +23,11 @@ public Mission saveDeletedMission(Stamp stamp, int order) { return missionRepository.save(mission); } + + public Mission saveCompletedMission(Stamp stamp, int order) { + Mission mission = MissionFixture.createMission(stamp, order); + mission.updateCompleted(); + + return missionRepository.save(mission); + } } diff --git a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java new file mode 100644 index 0000000..528a13b --- /dev/null +++ b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java @@ -0,0 +1,128 @@ +package com.ject.studytrip.pomodoro.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.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.pomodoro.domain.error.PomodoroErrorCode; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; +import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.DailyGoalFixture; +import com.ject.studytrip.trip.fixture.TripFixture; +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; + +@DisplayName("PomodoroService 단위 테스트") +public class PomodoroServiceTest extends BaseUnitTest { + + @InjectMocks private PomodoroService pomodoroService; + @Mock private PomodoroRepository pomodoroRepository; + + private DailyGoal dailyGoal; + private Pomodoro pomodoro; + + @BeforeEach + void setUp() { + Member member = MemberFixture.createMemberFromKakaoWithId(1L); + Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); + pomodoro = PomodoroFixture.createPomodoroWithId(1L, dailyGoal); + } + + @Nested + @DisplayName("뽀모도로를 생성한다") + class createPomodoro { + + @Test + @DisplayName("뽀모도로를 생성해 저장하고 반환한다") + void shouldCreateAndReturnPomodoro() { + // given + CreatePomodoroRequest request = new CreatePomodoroRequest(30); + given(pomodoroRepository.save(any())).willReturn(pomodoro); + + // when + Pomodoro result = pomodoroService.createPomodoro(dailyGoal, request); + + // then + assertThat(result).isNotNull(); + assertThat(result.getFocusDurationInSeconds()).isEqualTo(30 * 60); + } + } + + @Nested + @DisplayName("뽀모도로를 삭제한다") + class deletePomodoro { + + @Test + @DisplayName("deletedAt 필드를 설정해 삭제 처리한다") + void shouldSoftDeletePomodoro() { + // when + pomodoroService.deletePomodoro(pomodoro); + + // then + assertThat(pomodoro.getDeletedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("뽀모도로를 조회한다") + class getPomodoro { + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회해 반환한다") + void shouldReturnPomodoroByDailyGoalId() { + // given + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.of(pomodoro)); + + // when + Pomodoro result = pomodoroService.getValidPomodoroByDailyGoal(dailyGoal.getId()); + + // then + assertThat(result).isNotNull(); + assertThat(result.getDailyGoal().getId()).isEqualTo(dailyGoal.getId()); + } + + @Test + @DisplayName("뽀모도로가 존재하지 않으면 예외가 발생한다") + void shouldThrowExceptionIfPomodoroNotFound() { + // given + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> pomodoroService.getValidPomodoroByDailyGoal(dailyGoal.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(PomodoroErrorCode.POMODORO_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("삭제된 뽀모도로일 경우 예외가 발생한다") + void shouldThrowExceptionWhenDeletedPomodoro() { + // given + pomodoro.updateDeletedAt(); + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.of(pomodoro)); + + // when & then + assertThatThrownBy(() -> pomodoroService.getValidPomodoroByDailyGoal(dailyGoal.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(PomodoroErrorCode.POMODORO_ALREADY_DELETED.getMessage()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java b/src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java new file mode 100644 index 0000000..99ca45b --- /dev/null +++ b/src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.pomodoro.fixture; + +import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.test.util.ReflectionTestUtils; + +public class PomodoroFixture { + private static final int FOCUS_DURATION_SECONDS = 30 * 60; + private static final int FOCUS_COUNT = 1; + private static final int BREAK_DURATION_SECONDS = 0; + + public static Pomodoro createPomodoro(DailyGoal dailyGoal) { + return PomodoroFactory.create( + dailyGoal, FOCUS_DURATION_SECONDS, FOCUS_COUNT, BREAK_DURATION_SECONDS); + } + + public static Pomodoro createPomodoroWithId(Long id, DailyGoal dailyGoal) { + Pomodoro pomodoro = + PomodoroFactory.create( + dailyGoal, FOCUS_DURATION_SECONDS, FOCUS_COUNT, BREAK_DURATION_SECONDS); + ReflectionTestUtils.setField(pomodoro, "id", id); + + return pomodoro; + } +} diff --git a/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java b/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java new file mode 100644 index 0000000..3bb4c30 --- /dev/null +++ b/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java @@ -0,0 +1,25 @@ +package com.ject.studytrip.pomodoro.helper; + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; +import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class PomodoroTestHelper { + + @Autowired private PomodoroRepository pomodoroRepository; + + public Pomodoro savePomodoro(DailyGoal dailyGoal) { + return pomodoroRepository.save(PomodoroFixture.createPomodoro(dailyGoal)); + } + + public Pomodoro saveDeletedPomodoro(DailyGoal dailyGoal) { + Pomodoro pomodoro = pomodoroRepository.save(PomodoroFixture.createPomodoro(dailyGoal)); + pomodoro.updateDeletedAt(); + + return pomodoro; + } +} 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 243a922..6001aa6 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 @@ -558,5 +558,36 @@ void shouldThrowExceptionWhenStampAlreadyDeleted() { .isInstanceOf(CustomException.class) .hasMessage(StampErrorCode.STAMP_ALREADY_DELETED.getMessage()); } + + @Test + @DisplayName("코스형 여행 ID로 현재 진행중인 스탬프를 조회하고 반한환다") + void shouldReturnFirstIncompleteStampForCourseTrip() { + // given + given(stampQueryRepository.findFirstIncompleteStampByTripId(any())) + .willReturn(Optional.ofNullable(courseStamp1)); + + // when + Stamp stamp = stampService.getFirstInCompleteStampForCourseTrip(courseTrip.getId()); + + // then + assertThat(stamp.getId()).isEqualTo(courseStamp1.getId()); + assertThat(stamp.isCompleted()).isFalse(); + } + + @Test + @DisplayName("코스형 여행의 현재 진행중인 스탬프가 존재하지 않으면 예외가 발생한다") + void shouldThrowExceptionWhenNoIncompleteStampExistsForCourseTrip() { + // given + given(stampQueryRepository.findFirstIncompleteStampByTripId(any())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + stampService.getFirstInCompleteStampForCourseTrip( + courseTrip.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_NOT_FOUND.getMessage()); + } } } diff --git a/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java b/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java index 241ba2e..8fcfee0 100644 --- a/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java +++ b/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java @@ -22,4 +22,11 @@ public Stamp saveDeletedStamp(Trip trip, int order) { return stampRepository.save(stamp); } + + public Stamp saveCompletedStamp(Trip trip, int order) { + Stamp stamp = StampFixture.createStamp(trip, order); + stamp.updateCompleted(); + + return stampRepository.save(stamp); + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalServiceTest.java new file mode 100644 index 0000000..6a50c4d --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/application/service/DailyGoalServiceTest.java @@ -0,0 +1,140 @@ +package com.ject.studytrip.trip.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.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.trip.domain.error.DailyGoalErrorCode; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.repository.DailyGoalRepository; +import com.ject.studytrip.trip.fixture.DailyGoalFixture; +import com.ject.studytrip.trip.fixture.TripFixture; +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; + +@DisplayName("DailyGoalService 단위 테스트") +public class DailyGoalServiceTest extends BaseUnitTest { + + @InjectMocks private DailyGoalService dailyGoalService; + @Mock private DailyGoalRepository dailyGoalRepository; + + private Member member; + private Trip trip; + private DailyGoal dailyGoal; + + @BeforeEach + void setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); + } + + @Nested + @DisplayName("데일리 목표를 생성한다") + class CreateDailyGoal { + + @Test + @DisplayName("여행에 속한 데일리 목표를 생성하고 저장된 값을 반환한다") + void shouldCreateAndSaveDailyGoal() { + // given + given(dailyGoalRepository.save(any())).willReturn(dailyGoal); + + // when + DailyGoal result = dailyGoalService.createDailyGoal(trip); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTrip().getId()).isEqualTo(trip.getId()); + } + } + + @Nested + @DisplayName("데일리 목표를 삭제한다") + class DeleteDailyGoal { + + @Test + @DisplayName("deletedAt을 현재 시간으로 설정한다") + void shouldSoftDeleteDailyGoal() { + // when + dailyGoalService.deleteDailyGoal(dailyGoal); + + // then + assertThat(dailyGoal.getDeletedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("데일리 목표를 조회한다") + class GetDailyGoal { + + @Test + @DisplayName("ID로 조회된 데일리 목표가 trip에 속하고 삭제되지 않았다면 반환한다") + void shouldReturnValidDailyGoal() { + // given + given(dailyGoalRepository.findById(dailyGoal.getId())) + .willReturn(Optional.of(dailyGoal)); + + // when + DailyGoal result = dailyGoalService.getValidDailyGoal(trip.getId(), dailyGoal.getId()); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(dailyGoal.getId()); + } + + @Test + @DisplayName("데일리 목표가 존재하지 않으면 예외가 발생한다") + void shouldThrowExceptionWhenDailyGoalNotFound() { + // given + given(dailyGoalRepository.findById(any())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> dailyGoalService.getValidDailyGoal(trip.getId(), 999L)) + .isInstanceOf(CustomException.class) + .hasMessage(DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("다른 여행에 속한 데일리 목표일 경우 예외가 발생한다") + void shouldThrowExceptionWhenNotBelongToTrip() { + // given + Trip otherTrip = TripFixture.createTripWithId(999L, member, TripCategory.COURSE); + given(dailyGoalRepository.findById(dailyGoal.getId())) + .willReturn(Optional.of(dailyGoal)); + + // when & then + assertThatThrownBy( + () -> + dailyGoalService.getValidDailyGoal( + otherTrip.getId(), dailyGoal.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP.getMessage()); + } + + @Test + @DisplayName("삭제된 데일리 목표일 경우 예외가 발생한다") + void shouldThrowExceptionWhenDeletedDailyGoal() { + // given + DailyGoal deleted = DailyGoalFixture.createDeletedDailyGoal(trip); + given(dailyGoalRepository.findById(deleted.getId())).willReturn(Optional.of(deleted)); + + // when & then + assertThatThrownBy( + () -> dailyGoalService.getValidDailyGoal(trip.getId(), deleted.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED.getMessage()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/CreateDailyGoalRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/CreateDailyGoalRequestFixture.java new file mode 100644 index 0000000..9e391ac --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/CreateDailyGoalRequestFixture.java @@ -0,0 +1,25 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; +import com.ject.studytrip.trip.presentation.dto.request.CreateDailyGoalRequest; +import java.util.List; + +public class CreateDailyGoalRequestFixture { + + private CreatePomodoroRequest pomodoro = new CreatePomodoroRequest(30); + private List missionIds = List.of(1L, 2L); + + public CreateDailyGoalRequestFixture withPomodoro(CreatePomodoroRequest pomodoro) { + this.pomodoro = pomodoro; + return this; + } + + public CreateDailyGoalRequestFixture withMissionIds(List missionIds) { + this.missionIds = missionIds; + return this; + } + + public CreateDailyGoalRequest build() { + return new CreateDailyGoalRequest(pomodoro, missionIds); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/DailyGoalFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/DailyGoalFixture.java new file mode 100644 index 0000000..bbbae51 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/DailyGoalFixture.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.domain.factory.DailyGoalFactory; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import org.springframework.test.util.ReflectionTestUtils; + +public class DailyGoalFixture { + public static DailyGoal createDailyGoal(Trip trip) { + return DailyGoalFactory.create(trip); + } + + public static DailyGoal createDailyGoalWithId(Long id, Trip trip) { + DailyGoal dailyGoal = DailyGoalFactory.create(trip); + ReflectionTestUtils.setField(dailyGoal, "id", id); + + return dailyGoal; + } + + public static DailyGoal createDeletedDailyGoal(Trip trip) { + DailyGoal dailyGoal = DailyGoalFactory.create(trip); + dailyGoal.updateDeletedAt(); + + return dailyGoal; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/UpdateDailyGoalRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/UpdateDailyGoalRequestFixture.java new file mode 100644 index 0000000..6269f93 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/UpdateDailyGoalRequestFixture.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.presentation.dto.request.UpdateDailyGoalRequest; +import java.util.List; + +public class UpdateDailyGoalRequestFixture { + private List deleteDailyMissionIds = null; + private List addMissionIds = null; + + public UpdateDailyGoalRequestFixture withDeleteDailyMissionIds( + List deleteDailyMissionIds) { + this.deleteDailyMissionIds = deleteDailyMissionIds; + return this; + } + + public UpdateDailyGoalRequestFixture withAddMissionIds(List addMissionIds) { + this.addMissionIds = addMissionIds; + return this; + } + + public UpdateDailyGoalRequest build() { + return new UpdateDailyGoalRequest(deleteDailyMissionIds, addMissionIds); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/helper/DailyGoalTestHelper.java b/src/test/java/com/ject/studytrip/trip/helper/DailyGoalTestHelper.java new file mode 100644 index 0000000..34ec41c --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/helper/DailyGoalTestHelper.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.trip.helper; + +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.repository.DailyGoalRepository; +import com.ject.studytrip.trip.fixture.DailyGoalFixture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class DailyGoalTestHelper { + + @Autowired private DailyGoalRepository dailyGoalRepository; + + public DailyGoal saveDailyGoal(Trip trip) { + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoal(trip); + return dailyGoalRepository.save(dailyGoal); + } + + public DailyGoal saveDeletedDailyGoal(Trip trip) { + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoal(trip); + dailyGoal.updateDeletedAt(); + + return dailyGoalRepository.save(dailyGoal); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.java new file mode 100644 index 0000000..6bbf3f9 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.java @@ -0,0 +1,1304 @@ +package com.ject.studytrip.trip.presentation.controller; + +import static com.ject.studytrip.auth.fixture.TokenFixture.TOKEN_PREFIX; +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.helper.TokenTestHelper; +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.domain.model.MemberRole; +import com.ject.studytrip.member.helper.MemberTestHelper; +import com.ject.studytrip.mission.domain.error.DailyMissionErrorCode; +import com.ject.studytrip.mission.domain.error.MissionErrorCode; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +import com.ject.studytrip.mission.helper.DailyMissionTestHelper; +import com.ject.studytrip.mission.helper.MissionTestHelper; +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; +import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.helper.PomodoroTestHelper; +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.DailyGoalErrorCode; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.CreateDailyGoalRequestFixture; +import com.ject.studytrip.trip.fixture.UpdateDailyGoalRequestFixture; +import com.ject.studytrip.trip.helper.DailyGoalTestHelper; +import com.ject.studytrip.trip.helper.TripTestHelper; +import com.ject.studytrip.trip.presentation.dto.request.CreateDailyGoalRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateDailyGoalRequest; +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.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("DailyGoalController 통합 테스트") +public class DailyGoalControllerIntegrationTest extends BaseIntegrationTest { + private static final int DEFAULT_ORDER = 1; + + @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TripTestHelper tripTestHelper; + @Autowired private StampTestHelper stampTestHelper; + @Autowired private MissionTestHelper missionTestHelper; + @Autowired private DailyGoalTestHelper dailyGoalTestHelper; + @Autowired private PomodoroTestHelper pomodoroTestHelper; + @Autowired private DailyMissionTestHelper dailyMissionTestHelper; + @Autowired private TokenTestHelper tokenTestHelper; + + private Member member; + private Trip trip; + private Stamp stamp; + private Mission firstMission; + private Mission secondMission; + private DailyGoal dailyGoal; + private DailyMission dailyMission; + private String token; + + @BeforeEach + void setUp() { + member = memberTestHelper.saveMember(); + trip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stamp = stampTestHelper.saveStamp(trip, 1); + firstMission = missionTestHelper.saveMission(stamp, DEFAULT_ORDER); + secondMission = missionTestHelper.saveMission(stamp, DEFAULT_ORDER + 1); + dailyGoal = dailyGoalTestHelper.saveDailyGoal(trip); + dailyMission = dailyMissionTestHelper.saveDailyMission(firstMission, dailyGoal); + token = + tokenTestHelper.createAccessToken( + member.getId().toString(), MemberRole.ROLE_USER.name()); + } + + @Nested + @DisplayName("데일리 목표 생성 API") + class CreateDailyGoal { + private final CreateDailyGoalRequestFixture fixture = new CreateDailyGoalRequestFixture(); + + private ResultActions getResultActions( + String token, Object tripId, CreateDailyGoalRequest request) throws Exception { + return mockMvc.perform( + post("/api/trips/{tripId}/daily-goals", tripId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("유효한 요청으로 데일리 목표와 뽀모도로, 데일리 미션을 함께 생성한다") + void shouldCreateDailyGoalWithPomodoroAndDailyMissions() throws Exception { + // given + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(firstMission.getId(), secondMission.getId())) + .build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.dailyGoalId").isNumber()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // given + CreateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions("", trip.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 tripId = "abc"; + CreateDailyGoalRequest request = fixture.build(); + // when + ResultActions resultActions = getResultActions(token, tripId, 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 shouldReturnBadRequestWhenInvalidRequiredFields() throws Exception { + // given + CreateDailyGoalRequest request = + fixture.withPomodoro(null).withMissionIds(List.of()).build(); + // when + ResultActions resultActions = getResultActions(token, trip.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("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + CreateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions(token, tripId, request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + CreateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions(token, newTrip.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { + // given + Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + CreateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions(token, deleted.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("요청한 미션 ID 목록으로 해당 미션을 조회하고, 하나라도 일치하지 않는 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenAnyMissionIdDoesNotExist() throws Exception { + // given + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(firstMission.getId(), 1000L)).build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MissionErrorCode.MISSION_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("코스형 여행에서 현재 진행중인 스탬프(완료되지 않은 가장 첫번째 스탬프)가 없을 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenNotFoundWhenNoIncompleteStampExists() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stampTestHelper.saveCompletedStamp(newTrip, 1); + CreateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions(token, newTrip.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("조회된 미션들의 스탬프 정보를 확인해 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenMissionsStampsDoesNotBelongToTrip() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stampTestHelper.saveStamp(newTrip, 1); + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(firstMission.getId())).build(); + + // when + ResultActions resultActions = getResultActions(token, newTrip.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("조회된 미션들 중 삭제된 미션이 존재하면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenMissionIsDeleted() throws Exception { + // given + Mission deleted = missionTestHelper.saveDeletedMission(stamp, DEFAULT_ORDER); + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(deleted.getId())).build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("조회된 미션들 중 완료된 미션이 존재하면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenMissionIsAlreadyCompleted() throws Exception { + // given + Mission completed = missionTestHelper.saveCompletedMission(stamp, DEFAULT_ORDER); + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(completed.getId())).build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_COMPLETED + .getStatus() + .value())); + } + + @Test + @DisplayName("코스형 여행에서 조회된 미션들이 현재 진행중인 스탬프에 속하지 않은 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenMissionNotBelongToCurrentStamp() throws Exception { + // given + Stamp newStamp = stampTestHelper.saveStamp(trip, 2); + Mission newMission = missionTestHelper.saveMission(newStamp, DEFAULT_ORDER); + CreateDailyGoalRequest request = + fixture.withMissionIds(List.of(newMission.getId())).build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP + .getStatus() + .value())); + } + } + + @Nested + @DisplayName("데일리 목표 수정 API") + class UpdateDailyGoal { + private final UpdateDailyGoalRequestFixture fixture = new UpdateDailyGoalRequestFixture(); + + private ResultActions getResultActions( + String token, Object tripId, Object dailyGoalId, UpdateDailyGoalRequest request) + throws Exception { + return mockMvc.perform( + patch("/api/trips/{tripId}/daily-goals/{dailyGoalId}", tripId, dailyGoalId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("유효한 요청으로 특정 데일리 목표에 속한 데일리 미션을 수정한다") + void shouldUpdateDailyGoal() throws Exception { + // given + Mission addMission = missionTestHelper.saveMission(stamp, DEFAULT_ORDER); + UpdateDailyGoalRequest request = + fixture.withDeleteDailyMissionIds(List.of(dailyMission.getId())) + .withAddMissionIds(List.of(addMission.getId())) + .build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions.andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // given + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions("", trip.getId(), dailyGoal.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 tripId = "abc"; + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, tripId, dailyGoal.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 shouldReturnBadRequestWhenDailyGoalIdTypeMismatch() throws Exception { + // given + String dailyGoalId = "abc"; + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoalId, request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, tripId, dailyGoal.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("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, newTrip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { + // given + Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, deleted.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 데일리 목표 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidDailyGoalId() throws Exception { + // given + Long dailyGoalId = 10000L; + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoalId, request); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("조회된 데일리 목표가 요청한 여행에 속하지 않을 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenDailyGoalNotBelongToTrip() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip); + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), newDailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 데일리 목표일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyDailyGoal() throws Exception { + // given + DailyGoal deleted = dailyGoalTestHelper.saveDeletedDailyGoal(trip); + UpdateDailyGoalRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), deleted.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제를 요청한 데일리 미션 ID 개수와 조회된 데일리 미션 개수가 다르면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenAnyDeleteTargetDailyMissionDoesNotExist() throws Exception { + // given + List ids = List.of(dailyMission.getId(), 1000L); + UpdateDailyGoalRequest request = fixture.withDeleteDailyMissionIds(ids).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제를 요청한 데일리 미션이 요청한 데일리 목표에 속하지 않으면 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenDeleteTargetDailyMissionDoesNotBelongToDailyGoal() + throws Exception { + // given + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(trip); + DailyMission newDailyMission = + dailyMissionTestHelper.saveDailyMission(firstMission, newDailyGoal); + UpdateDailyGoalRequest request = + fixture.withDeleteDailyMissionIds(List.of(newDailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyMissionErrorCode + .DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL + .getStatus() + .value())); + } + + @Test + @DisplayName("데일리 미션이 이미 삭제된 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenDeleteTargetDailyMissionIsAlreadyDeleted() throws Exception { + // given + dailyMission.updateDeletedAt(); + UpdateDailyGoalRequest request = + fixture.withDeleteDailyMissionIds(List.of(dailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("추가할 미션 ID 목록으로 해당 미션을 조회하고, 하나라도 일치하지 않는 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenAnyAddMissionIdDoesNotExist() throws Exception { + // given + UpdateDailyGoalRequest request = + fixture.withAddMissionIds(List.of(100L, 200L, 300L)).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MissionErrorCode.MISSION_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("코스형 여행에서 현재 진행중인 스탬프(완료되지 않은 가장 첫번째 스탬프)가 없을 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenNoIncompleteStampExists() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stampTestHelper.saveCompletedStamp(newTrip, 1); + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip); + UpdateDailyGoalRequest request = fixture.withAddMissionIds(List.of(1L)).build(); + + // when + ResultActions resultActions = + getResultActions(token, newTrip.getId(), newDailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(StampErrorCode.STAMP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("새로 추가할 미션들의 스탬프 정보를 확인해 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenAddMissionsStampsDoesNotBelongToTrip() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + Stamp newStamp = stampTestHelper.saveStamp(newTrip, 1); + Mission newMission = missionTestHelper.saveMission(newStamp, DEFAULT_ORDER); + UpdateDailyGoalRequest request = + fixture.withAddMissionIds(List.of(newMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.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("새로 추가할 미션들 중 삭제된 미션이 존재하면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAnyAddMissionIsDeleted() throws Exception { + // given + Mission deleted = missionTestHelper.saveDeletedMission(stamp, DEFAULT_ORDER); + UpdateDailyGoalRequest request = + fixture.withAddMissionIds(List.of(deleted.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("새로 추가할 미션들 중 완료된 미션이 존재하면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAnyAddMissionIsAlreadyCompleted() throws Exception { + // given + Mission completed = missionTestHelper.saveCompletedMission(stamp, DEFAULT_ORDER); + UpdateDailyGoalRequest request = + fixture.withAddMissionIds(List.of(completed.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_COMPLETED + .getStatus() + .value())); + } + + @Test + @DisplayName("코스형 여행에서 새로 추가한 미션들이 현재 진행중인 스탬프에 속하지 않은 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAddMissionsDoNotBelongToCurrentStamp() throws Exception { + // given + Stamp newStamp = stampTestHelper.saveStamp(trip, 2); + Mission newMission = missionTestHelper.saveMission(newStamp, DEFAULT_ORDER); + UpdateDailyGoalRequest request = + fixture.withAddMissionIds(List.of(newMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP + .getStatus() + .value())); + } + } + + @Nested + @DisplayName("데일리 목표 삭제 API") + class DeleteDailyGoal { + + private ResultActions getResultActions(String token, Object tripId, Object dailyGoalId) + throws Exception { + return mockMvc.perform( + delete("/api/trips/{tripId}/daily-goals/{dailyGoalId}", tripId, dailyGoalId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("유효한 요청으로 데일리 목표와 뽀모도로, 데일리 미션을 삭제한다") + void shouldDeleteDailyGoalAndPomodoroAndDailyMissions() throws Exception { + // given + pomodoroTestHelper.savePomodoro(dailyGoal); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions.andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = getResultActions("", trip.getId(), dailyGoal.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 tripId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, tripId, dailyGoal.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 shouldReturnBadRequestWhenDailyGoalIdTypeMismatch() throws Exception { + // given + String dailyGoalId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoalId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, tripId, dailyGoal.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + + // when + ResultActions resultActions = + getResultActions(token, newTrip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { + // given + Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + + // when + ResultActions resultActions = + getResultActions(token, deleted.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 데일리 목표 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidDailyGoalId() throws Exception { + // given + Long dailyGoalId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoalId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("조회된 데일리 목표가 요청한 여행에 속하지 않을 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenDailyGoalNotBelongToTrip() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), newDailyGoal.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 데일리 목표일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyDailyGoal() throws Exception { + // given + DailyGoal deleted = dailyGoalTestHelper.saveDeletedDailyGoal(trip); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), deleted.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회하고 존재하지 않을 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenPomodoroDoesNotExist() throws Exception { + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회하고 이미 삭제된 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenPomodoroIsAlreadyDeleted() throws Exception { + // given + pomodoroTestHelper.saveDeletedPomodoro(dailyGoal); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_ALREADY_DELETED + .getStatus() + .value())); + } + } + + @Nested + @DisplayName("데일리 목표 조회 API") + class GetDailyGoal { + + private ResultActions getResultActions(String token, Object tripId, Object dailyGoalId) + throws Exception { + return mockMvc.perform( + get("/api/trips/{tripId}/daily-goals/{dailyGoalId}", tripId, dailyGoalId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("유효한 정보로 특정 데일리 목표를 조회하고 해당 뽀모도로와 데일리 미션을 함께 반환한다") + void shouldReturnDailyGoal() throws Exception { + // given + Pomodoro pomodoro = pomodoroTestHelper.savePomodoro(dailyGoal); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.dailyGoalId").value(dailyGoal.getId())) + .andExpect(jsonPath("$.data.pomodoro.pomodoroId").value(pomodoro.getId())) + .andExpect(jsonPath("$.data.dailyMissions").isNotEmpty()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = getResultActions("", trip.getId(), dailyGoal.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 tripId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, tripId, dailyGoal.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 shouldReturnBadRequestWhenDailyGoalIdTypeMismatch() throws Exception { + // given + String dailyGoalId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoalId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, tripId, dailyGoal.getId()); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + + // when + ResultActions resultActions = + getResultActions(token, newTrip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { + // given + Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + + // when + ResultActions resultActions = + getResultActions(token, deleted.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 데일리 목표 ID 라면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenInvalidDailyGoalId() throws Exception { + // given + Long dailyGoalId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoalId); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("조회된 데일리 목표가 요청한 여행에 속하지 않을 경우 403 Forbidden을 반환한다") + void shouldReturnForbiddenWhenDailyGoalNotBelongToTrip() throws Exception { + // given + Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip); + + // when + ResultActions resultActions = + getResultActions(token, trip.getId(), newDailyGoal.getId()); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 데일리 목표일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenAlreadyDailyGoal() throws Exception { + // given + DailyGoal deleted = dailyGoalTestHelper.saveDeletedDailyGoal(trip); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), deleted.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회하고 존재하지 않을 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenPomodoroDoesNotExist() throws Exception { + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회하고 이미 삭제된 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenPomodoroIsAlreadyDeleted() throws Exception { + // given + pomodoroTestHelper.saveDeletedPomodoro(dailyGoal); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), dailyGoal.getId()); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_ALREADY_DELETED + .getStatus() + .value())); + } + } +}