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 index 1969e2d..7903b3e 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/DailyMissionService.java @@ -7,6 +7,7 @@ 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 com.ject.studytrip.trip.domain.model.TripCategory; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -44,7 +45,27 @@ public List getValidDailyMissionsByIds( return dailyMissions; } + public List getValidDailyMissionByIdsWithMissionAndStamp( + Long dailyGoalId, List dailyMissionIds) { + List dailyMissions = + dailyMissionQueryRepository.findAllByIdsFetchJoinMissionAndStamp(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); } + + public void validateSelectedDailyMissions( + TripCategory tripCategory, List dailyMissions) { + DailyMissionPolicy.validateCourseTripStampConsistency(tripCategory, dailyMissions); + } } 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 5d80384..d26eddd 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java @@ -69,6 +69,13 @@ public void updateMissionOrders(Long stampId, UpdateMissionOrderRequest request) } } + public void updateCompleted(Mission mission) { + MissionPolicy.validateNotDeleted(mission); + MissionPolicy.validateCompleted(mission); + + mission.updateCompleted(); + } + @Transactional public void deleteMission(Long stampId, Mission mission) { validateMissionIsActiveAndBelongsToStamp(stampId, mission); 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 index ebbba03..2920b6e 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/error/DailyMissionErrorCode.java +++ b/src/main/java/com/ject/studytrip/mission/domain/error/DailyMissionErrorCode.java @@ -8,6 +8,7 @@ public enum DailyMissionErrorCode implements ErrorCode { // 400 DAILY_MISSION_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 데일리 미션입니다."), + COURSE_TRIP_STAMP_MISMATCH(HttpStatus.BAD_REQUEST, "코스형 여행의 데일리 미션들은 모두 동일한 스탬프여야 합니다."), // 403 DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL( 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 index 67890af..22b7ca6 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java +++ b/src/main/java/com/ject/studytrip/mission/domain/policy/DailyMissionPolicy.java @@ -3,7 +3,10 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.mission.domain.error.DailyMissionErrorCode; import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.trip.domain.model.TripCategory; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -24,4 +27,18 @@ public static void validateNotDeleted(DailyMission dailyMission) { if (dailyMission.getDeletedAt() != null) throw new CustomException(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED); } + + public static void validateCourseTripStampConsistency( + TripCategory tripCategory, List dailyMissions) { + if (tripCategory != TripCategory.COURSE) return; + + Set stampIds = + dailyMissions.stream() + .map(dailyMission -> dailyMission.getMission().getStamp().getId()) + .collect(Collectors.toSet()); + + if (stampIds.size() != 1) { + throw new CustomException(DailyMissionErrorCode.COURSE_TRIP_STAMP_MISMATCH); + } + } } 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 index 4cbda68..92c5515 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/DailyMissionQueryRepository.java @@ -5,4 +5,6 @@ public interface DailyMissionQueryRepository { List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId); + + List findAllByIdsFetchJoinMissionAndStamp(List ids); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java similarity index 66% rename from src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java rename to src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java index d6d5bff..3a8394d 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/jpa/DailyMissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/DailyMissionQueryRepositoryAdapter.java @@ -1,9 +1,10 @@ -package com.ject.studytrip.mission.infra.jpa; +package com.ject.studytrip.mission.infra.querydsl; 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.ject.studytrip.stamp.domain.model.QStamp; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ public class DailyMissionQueryRepositoryAdapter implements DailyMissionQueryRepo private final JPAQueryFactory queryFactory; private final QDailyMission dailyMission = QDailyMission.dailyMission; private final QMission mission = QMission.mission; + private final QStamp stamp = QStamp.stamp; @Override public List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId) { @@ -25,4 +27,16 @@ public List findAllByDailyGoalIdFetchJoinMission(Long dailyGoalId) .where(dailyMission.dailyGoal.id.eq(dailyGoalId), dailyMission.deletedAt.isNull()) .fetch(); } + + @Override + public List findAllByIdsFetchJoinMissionAndStamp(List ids) { + return queryFactory + .selectFrom(dailyMission) + .join(dailyMission.mission, mission) + .fetchJoin() + .join(mission.stamp, stamp) + .fetchJoin() + .where(dailyMission.id.in(ids)) + .fetch(); + } } diff --git a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java similarity index 95% rename from src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java rename to src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java index 860809b..e91ceb5 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/jpa/MissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java @@ -1,4 +1,4 @@ -package com.ject.studytrip.mission.infra.jpa; +package com.ject.studytrip.mission.infra.querydsl; import com.ject.studytrip.mission.domain.model.Mission; import com.ject.studytrip.mission.domain.model.QMission; 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 index 16fae6d..538f6c4 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java +++ b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroService.java @@ -37,4 +37,18 @@ public Pomodoro getValidPomodoroByDailyGoal(Long dailyGoalId) { return pomodoro; } + + public void updateTotalFocusTime(Long dailyGoalId, int totalFocusTimeInMinutes) { + PomodoroPolicy.validateTotalFocusTimeNotNegative(totalFocusTimeInMinutes); + + Pomodoro pomodoro = + pomodoroRepository + .findByDailyGoalId(dailyGoalId) + .orElseThrow( + () -> new CustomException(PomodoroErrorCode.POMODORO_NOT_FOUND)); + PomodoroPolicy.validateNotDeleted(pomodoro); + + int totalFocusTimeInSeconds = totalFocusTimeInMinutes * 60; + pomodoro.updateTotalFocusTimeInSeconds(totalFocusTimeInSeconds); + } } 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 index b9fe5c6..77b07f5 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java @@ -8,6 +8,7 @@ public enum PomodoroErrorCode implements ErrorCode { // 400 POMODORO_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 뽀모도로입니다."), + POMODORO_NEGATIVE_FOCUS_TIME(HttpStatus.BAD_REQUEST, "뽀모도로 총 집중시간(분)은 음수일 수 없습니다."), // 404 POMODORO_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 뽀모도로 정보를 찾을 수 없습니다."), 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 75847ad..9a9a18c 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 @@ -42,6 +42,10 @@ public static Pomodoro of( .build(); } + public void updateTotalFocusTimeInSeconds(int totalFocusTimeInSeconds) { + this.totalFocusTimeInSeconds = totalFocusTimeInSeconds; + } + 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 index 2d3a161..7f3c8be 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java @@ -12,4 +12,10 @@ public static void validateNotDeleted(Pomodoro pomodoro) { if (pomodoro.getDeletedAt() != null) throw new CustomException(PomodoroErrorCode.POMODORO_ALREADY_DELETED); } + + public static void validateTotalFocusTimeNotNegative(int totalFocusTime) { + if (totalFocusTime < 0) { + throw new CustomException(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME); + } + } } 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 a2217b9..d850b13 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 @@ -12,9 +12,7 @@ import com.ject.studytrip.stamp.presentation.dto.request.UpdateStampOrderRequest; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -23,7 +21,6 @@ @Service @RequiredArgsConstructor public class StampService { - private final StampRepository stampRepository; private final StampQueryRepository stampQueryRepository; @@ -137,6 +134,44 @@ public Stamp getFirstInCompleteStampForCourseTrip(Long tripId) { .orElseThrow(() -> new CustomException(StampErrorCode.STAMP_NOT_FOUND)); } + public String getStampNameByTripCategory(TripCategory tripCategory, List stamps) { + // 스탬프 목록이 비어있지 않은지 검증 + StampPolicy.validateStampListNotEmpty(stamps); + stamps.forEach(StampPolicy::validateNotDeleted); + + if (tripCategory == TripCategory.COURSE) { + return getCourseStampName(stamps); + } + + return getExplorationStampName(stamps); + } + + private String getCourseStampName(List stamps) { + return new HashSet<>(stamps).iterator().next().getName(); + } + + private String getExplorationStampName(List stamps) { + // 스탬프별 개수 집계 + Map stampCountMap = + stamps.stream() + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + + // 최대 개수를 가진 스탬프들 찾기 + long maxCount = stampCountMap.values().stream().max(Long::compareTo).orElse(0L); + + List maxCountStamps = + stampCountMap.entrySet().stream() + .filter(entry -> entry.getValue().equals(maxCount)) + .map(Map.Entry::getKey) + .toList(); + + // 가장 이른 생성 시간을 가진 스탬프 선택 + return maxCountStamps.stream() + .min(Comparator.comparing(Stamp::getCreatedAt)) + .map(Stamp::getName) + .orElse(""); + } + public void validateStampBelongsToTrip(Long tripId, Stamp stamp) { StampPolicy.validateStampBelongsToTrip(tripId, stamp); } diff --git a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java index 5cfd051..39e0a24 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java @@ -17,6 +17,7 @@ public enum StampErrorCode implements ErrorCode { STAMP_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 스탬프입니다."), CANNOT_UPDATE_ORDER_FOR_EXPLORATION_TRIP(HttpStatus.BAD_REQUEST, "탐험형 여행의 스탬프 순서는 변경할 수 없습니다."), INVALID_STAMP_ID_IN_REQUEST(HttpStatus.BAD_REQUEST, "존재하지 않는 스탬프 ID가 포함되어 있습니다. "), + STAMP_LIST_CANNOT_BE_EMPTY(HttpStatus.BAD_REQUEST, "스탬프 목록은 비어있을 수 없습니다."), // 403 STAMP_NOT_BELONG_TO_TRIP(HttpStatus.FORBIDDEN, "해당 스탬프는 요청한 여행에 속하지 않습니다."), diff --git a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java index ee02dab..e0ce819 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java @@ -70,4 +70,10 @@ public static void validateUpdateStampOrders( if (orderedStampIds.size() != savedStamps.size()) throw new CustomException(StampErrorCode.INVALID_STAMP_ID_IN_REQUEST); } + + public static void validateStampListNotEmpty(List stamps) { + if (stamps.isEmpty()) { + throw new CustomException(StampErrorCode.STAMP_LIST_CANNOT_BE_EMPTY); + } + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDailyMissionInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDailyMissionInfo.java new file mode 100644 index 0000000..31f2bda --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDailyMissionInfo.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.studylog.application.dto; + +import com.ject.studytrip.mission.application.dto.DailyMissionInfo; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; + +public record StudyLogDailyMissionInfo( + Long studyLogDailyMissionId, DailyMissionInfo dailyMissionInfo) { + public static StudyLogDailyMissionInfo from(StudyLogDailyMission studyLogDailyMission) { + return new StudyLogDailyMissionInfo( + studyLogDailyMission.getId(), + DailyMissionInfo.from(studyLogDailyMission.getDailyMission())); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java new file mode 100644 index 0000000..113066b --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java @@ -0,0 +1,15 @@ +package com.ject.studytrip.studylog.application.dto; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import java.util.List; + +public record StudyLogDetail( + StudyLogInfo studyLogInfo, List studyLogDailyMissionInfos) { + public static StudyLogDetail from( + StudyLog studyLog, List studyLogDailyMissions) { + return new StudyLogDetail( + StudyLogInfo.from(studyLog), + studyLogDailyMissions.stream().map(StudyLogDailyMissionInfo::from).toList()); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java new file mode 100644 index 0000000..afde52a --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.studylog.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.studylog.domain.model.StudyLog; + +public record StudyLogInfo( + Long studyLogId, + String title, + String content, + String createdAt, + String updatedAt, + String deletedAt) { + public static StudyLogInfo from(StudyLog studyLog) { + return new StudyLogInfo( + studyLog.getId(), + studyLog.getTitle(), + studyLog.getContent(), + DateUtil.formatDateTime(studyLog.getCreatedAt()), + DateUtil.formatDateTime(studyLog.getUpdatedAt()), + DateUtil.formatDateTime(studyLog.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java new file mode 100644 index 0000000..e7e5899 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java @@ -0,0 +1,142 @@ +package com.ject.studytrip.studylog.application.facade; + +import com.ject.studytrip.member.domain.model.Member; +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.pomodoro.application.service.PomodoroService; +import com.ject.studytrip.stamp.application.service.StampService; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.studylog.application.dto.StudyLogDetail; +import com.ject.studytrip.studylog.application.dto.StudyLogInfo; +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionService; +import com.ject.studytrip.studylog.application.service.StudyLogService; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +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 java.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StudyLogFacade { + private final TripService tripService; + private final StampService stampService; + private final MissionService missionService; + private final DailyGoalService dailyGoalService; + private final DailyMissionService dailyMissionService; + private final PomodoroService pomodoroService; + private final StudyLogService studyLogService; + private final StudyLogDailyMissionService studyLogDailyMissionService; + + @Transactional + public StudyLogInfo createStudyLog( + Long memberId, Long tripId, Long dailyGoalId, CreateStudyLogRequest request) { + // 1. 유효성 검증 및 엔티티 조회 + Trip trip = tripService.getValidTrip(memberId, tripId); + DailyGoal dailyGoal = dailyGoalService.getValidDailyGoal(trip.getId(), dailyGoalId); + List selectedDailyMissions = + getValidatedDailyMissions(dailyGoal.getId(), request); + + // 2. 학습 로그 생성 + StudyLog studyLog = + createStudyLogWithTitle( + trip.getMember(), + dailyGoal, + trip.getCategory(), + selectedDailyMissions, + request); + + // 3. 뽀모도로 총 학습시간 업데이트 + pomodoroService.updateTotalFocusTime(dailyGoalId, request.totalFocusTimeInMinutes()); + + // 4. 연관 데이터 생성 및 미션 완료 처리 + createStudyLogDailyMissionsAndCompleteMissions(studyLog, selectedDailyMissions); + + return StudyLogInfo.from(studyLog); + } + + private List getValidatedDailyMissions( + Long dailyGoalId, CreateStudyLogRequest request) { + List selectedDailyMissions = + dailyMissionService.getValidDailyMissionByIdsWithMissionAndStamp( + dailyGoalId, request.selectedDailyMissionIds()); + dailyMissionService.validateSelectedDailyMissions( + TripCategory.COURSE, selectedDailyMissions); + return selectedDailyMissions; + } + + private StudyLog createStudyLogWithTitle( + Member member, + DailyGoal dailyGoal, + TripCategory tripCategory, + List selectedDailyMissions, + CreateStudyLogRequest request) { + String title = determineTitleByStamps(tripCategory, selectedDailyMissions); + return studyLogService.createStudyLog(member, dailyGoal, title, request.content()); + } + + private String determineTitleByStamps( + TripCategory tripCategory, List selectedDailyMissions) { + List stamps = + selectedDailyMissions.stream() + .map(dailyMission -> dailyMission.getMission().getStamp()) + .toList(); + + return stampService.getStampNameByTripCategory(tripCategory, stamps); + } + + private void createStudyLogDailyMissionsAndCompleteMissions( + StudyLog studyLog, List selectedDailyMissions) { + // 학습 로그 데일리 미션 저장 + studyLogDailyMissionService.createStudyLogDailyMissions(studyLog, selectedDailyMissions); + + // 미션 완료 처리 + selectedDailyMissions.forEach( + dailyMission -> missionService.updateCompleted(dailyMission.getMission())); + } + + @Transactional(readOnly = true) + public Slice getStudyLogsByTrip( + Long memberId, Long tripId, int page, int size) { + // 1. 유효성 검증 및 엔티티 조회 + Trip trip = tripService.getValidTrip(memberId, tripId); + + // 2. 페이징된 학습 로그 목록 조회 + Slice studyLogSlice = + studyLogService.getStudyLogsSliceByTripId(trip.getId(), page, size); + + // 3. 학습 로그 상세 정보 구성 + return buildStudyLogDetailsSlice(studyLogSlice); + } + + private Slice buildStudyLogDetailsSlice(Slice studyLogSlice) { + List studyLogIds = studyLogSlice.getContent().stream().map(StudyLog::getId).toList(); + + // 학습 로그별 학습 로그 데일리 미션 목록 그룹화 + Map> groupedStudyLogDailyMissions = + studyLogDailyMissionService.getGroupedStudyLogDailyMissionsByStudyLogIds( + studyLogIds); + + List studyLogDetails = + studyLogSlice.getContent().stream() + .map( + studyLog -> + StudyLogDetail.from( + studyLog, + groupedStudyLogDailyMissions.get(studyLog.getId()))) + .toList(); + + return new SliceImpl<>( + studyLogDetails, studyLogSlice.getPageable(), studyLogSlice.hasNext()); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionService.java new file mode 100644 index 0000000..a8700e3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionService.java @@ -0,0 +1,37 @@ +package com.ject.studytrip.studylog.application.service; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.studylog.domain.factory.StudyLogDailyMissionFactory; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionQueryRepository; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionRepository; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StudyLogDailyMissionService { + private final StudyLogDailyMissionRepository studyLogDailyMissionRepository; + private final StudyLogDailyMissionQueryRepository studyLogDailyMissionQueryRepository; + + public List createStudyLogDailyMissions( + StudyLog studyLog, List dailyMissions) { + List studyLogDailyMissions = + dailyMissions.stream() + .map( + dailyMission -> + StudyLogDailyMissionFactory.create(studyLog, dailyMission)) + .toList(); + + return studyLogDailyMissionRepository.saveAll(studyLogDailyMissions); + } + + public Map> getGroupedStudyLogDailyMissionsByStudyLogIds( + List studyLogIds) { + return studyLogDailyMissionQueryRepository.findStudyLogDailyMissionsGroupedByStudyLogId( + studyLogIds); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java index 9c253a2..e45f86e 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java @@ -1,17 +1,36 @@ package com.ject.studytrip.studylog.application.service; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.studylog.domain.factory.StudyLogFactory; +import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import com.ject.studytrip.trip.domain.model.DailyGoal; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class StudyLogService { + private final StudyLogRepository studyLogRepository; private final StudyLogQueryRepository studyLogQueryRepository; @Transactional(readOnly = true) public long getActiveStudyLogCountByMemberId(Long memberId) { return studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId); } + + public StudyLog createStudyLog( + Member member, DailyGoal dailyGoal, String title, String content) { + StudyLog studyLog = StudyLogFactory.create(member, dailyGoal, title, content); + return studyLogRepository.save(studyLog); + } + + public Slice getStudyLogsSliceByTripId(Long tripId, int page, int size) { + return studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc( + tripId, PageRequest.of(page, size)); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java b/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java new file mode 100644 index 0000000..784ae34 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.studylog.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum StudyLogErrorCode implements ErrorCode { + ; + + 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/studylog/domain/factory/StudyLogDailyMissionFactory.java b/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogDailyMissionFactory.java new file mode 100644 index 0000000..de8ecce --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogDailyMissionFactory.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.studylog.domain.factory; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StudyLogDailyMissionFactory { + public static StudyLogDailyMission create(StudyLog studyLog, DailyMission dailyMission) { + return StudyLogDailyMission.of(studyLog, dailyMission); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java new file mode 100644 index 0000000..8db3b17 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java @@ -0,0 +1,30 @@ +package com.ject.studytrip.studylog.domain.model; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.mission.domain.model.DailyMission; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class StudyLogDailyMission extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_log_id", nullable = false) + private StudyLog studyLog; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "daily_mission_id", nullable = false) + private DailyMission dailyMission; + + public static StudyLogDailyMission of(StudyLog studyLog, DailyMission dailyMission) { + return StudyLogDailyMission.builder().studyLog(studyLog).dailyMission(dailyMission).build(); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java b/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java new file mode 100644 index 0000000..c014e90 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.studylog.domain.policy; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StudyLogPolicy {} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java new file mode 100644 index 0000000..5987135 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionQueryRepository.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.studylog.domain.repository; + +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import java.util.List; +import java.util.Map; + +public interface StudyLogDailyMissionQueryRepository { + Map> findStudyLogDailyMissionsGroupedByStudyLogId( + List studyLogIds); +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionRepository.java new file mode 100644 index 0000000..61ed208 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogDailyMissionRepository.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.domain.repository; + +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import java.util.List; + +public interface StudyLogDailyMissionRepository { + List saveAll(List studyLogDailyMissions); +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java index 39e876d..73362b7 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java @@ -1,5 +1,11 @@ package com.ject.studytrip.studylog.domain.repository; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + public interface StudyLogQueryRepository { long countActiveStudyLogsByMemberId(Long memberId); + + Slice findSliceByTripIdOrderByCreatedAtDesc(Long tripId, Pageable pageable); } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java new file mode 100644 index 0000000..5bfd95b --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.domain.repository; + +import com.ject.studytrip.studylog.domain.model.StudyLog; + +public interface StudyLogRepository { + + StudyLog save(StudyLog studyLog); +} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionJpaRepository.java b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionJpaRepository.java new file mode 100644 index 0000000..94f1fae --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionJpaRepository.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.studylog.infra.jpa; + +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyLogDailyMissionJpaRepository + extends JpaRepository {} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionRepositoryAdapter.java new file mode 100644 index 0000000..0e071ae --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogDailyMissionRepositoryAdapter.java @@ -0,0 +1,18 @@ +package com.ject.studytrip.studylog.infra.jpa; + +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StudyLogDailyMissionRepositoryAdapter implements StudyLogDailyMissionRepository { + private final StudyLogDailyMissionJpaRepository studyLogDailyMissionJpaRepository; + + @Override + public List saveAll(List studyLogDailyMissions) { + return studyLogDailyMissionJpaRepository.saveAll(studyLogDailyMissions); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java new file mode 100644 index 0000000..6f3c12d --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.studylog.infra.jpa; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyLogJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java new file mode 100644 index 0000000..9bdbc04 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.studylog.infra.jpa; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StudyLogRepositoryAdapter implements StudyLogRepository { + private final StudyLogJpaRepository studyLogJpaRepository; + + @Override + public StudyLog save(StudyLog studyLog) { + return studyLogJpaRepository.save(studyLog); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java new file mode 100644 index 0000000..bda3ab7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogDailyMissionQueryRepositoryAdapter.java @@ -0,0 +1,39 @@ +package com.ject.studytrip.studylog.infra.querydsl; + +import com.ject.studytrip.mission.domain.model.QDailyMission; +import com.ject.studytrip.mission.domain.model.QMission; +import com.ject.studytrip.studylog.domain.model.QStudyLogDailyMission; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StudyLogDailyMissionQueryRepositoryAdapter + implements StudyLogDailyMissionQueryRepository { + private final JPAQueryFactory queryFactory; + private final QStudyLogDailyMission studyLogDailyMission = + QStudyLogDailyMission.studyLogDailyMission; + private final QDailyMission dailyMission = QDailyMission.dailyMission; + private final QMission mission = QMission.mission; + + @Override + public Map> findStudyLogDailyMissionsGroupedByStudyLogId( + List studyLogIds) { + return queryFactory + .selectFrom(studyLogDailyMission) + .join(studyLogDailyMission.dailyMission, dailyMission) + .fetchJoin() + .join(dailyMission.mission, mission) + .fetchJoin() + .where(studyLogDailyMission.studyLog.id.in(studyLogIds)) + .fetch() + .stream() + .collect(Collectors.groupingBy(sldm -> sldm.getStudyLog().getId())); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java index 3654222..4e69efc 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java @@ -1,10 +1,16 @@ package com.ject.studytrip.studylog.infra.querydsl; import com.ject.studytrip.studylog.domain.model.QStudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +import com.ject.studytrip.trip.domain.model.QDailyGoal; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; @Repository @@ -12,6 +18,7 @@ public class StudyLogQueryRepositoryAdapter implements StudyLogQueryRepository { private final JPAQueryFactory queryFactory; private final QStudyLog studyLog = QStudyLog.studyLog; + private final QDailyGoal dailyGoal = QDailyGoal.dailyGoal; @Override public long countActiveStudyLogsByMemberId(Long memberId) { @@ -24,4 +31,25 @@ public long countActiveStudyLogsByMemberId(Long memberId) { return Optional.ofNullable(count).orElse(0L); } + + @Override + public Slice findSliceByTripIdOrderByCreatedAtDesc(Long tripId, Pageable pageable) { + List content = + queryFactory + .selectFrom(studyLog) + .join(studyLog.dailyGoal, dailyGoal) + .where(dailyGoal.trip.id.eq(tripId), dailyGoal.deletedAt.isNull()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .orderBy(studyLog.createdAt.desc()) + .fetch(); + + List result = content; + boolean hasNext = content.size() > pageable.getPageSize(); + if (hasNext) { + result = content.subList(0, pageable.getPageSize()); + } + + return new SliceImpl<>(result, pageable, hasNext); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java new file mode 100644 index 0000000..ac75bc7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java @@ -0,0 +1,64 @@ +package com.ject.studytrip.studylog.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.studylog.application.dto.StudyLogDetail; +import com.ject.studytrip.studylog.application.dto.StudyLogInfo; +import com.ject.studytrip.studylog.application.facade.StudyLogFacade; +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +import com.ject.studytrip.studylog.presentation.dto.response.CreateStudyLogResponse; +import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +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 = "StudyLog", description = "학습 로그 API") +@RestController +@RequiredArgsConstructor +@Validated +public class StudyLogController { + private final StudyLogFacade studyLogFacade; + + @Operation(summary = "학습 로그 생성", description = "학습을 완료한 데일리 미션을 선택해 학습 로그를 생성하는 API 입니다.") + @PostMapping("/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs") + public ResponseEntity createStudyLog( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") Long dailyGoalId, + @RequestBody @Valid CreateStudyLogRequest request) { + StudyLogInfo result = + studyLogFacade.createStudyLog(Long.valueOf(memberId), tripId, dailyGoalId, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + StandardResponse.success( + HttpStatus.CREATED.value(), CreateStudyLogResponse.of(result))); + } + + @Operation( + summary = "여행의 학습 로그 목록 조회", + description = "특정 여행의 학습 로그 목록을 조회하는 API 입니다. 슬라이스를 적용하고 최신순으로 정렬합니다.") + @GetMapping("/api/trips/{tripId}/study-logs") + public ResponseEntity loadStudyLogsByTrip( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size) { + Slice result = + studyLogFacade.getStudyLogsByTrip(Long.valueOf(memberId), tripId, page, size); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), LoadStudyLogsSliceResponse.of(result))); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java new file mode 100644 index 0000000..a085066 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.studylog.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record CreateStudyLogRequest( + @Schema(description = "뽀모도로 총 집중시간(분)") @Min(value = 0, message = "총 집중시간(분)은 음수일 수 없습니다.") + int totalFocusTimeInMinutes, + @Schema(description = "선택한 데일리 미션 ID 목록") + @NotEmpty(message = "학습로그를 작성할 데일리 미션 목록은 필수 요청 값입니다.") + List selectedDailyMissionIds, + @Schema(description = "학습 내용") String content) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java new file mode 100644 index 0000000..461a370 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.studylog.presentation.dto.response; + +import com.ject.studytrip.studylog.application.dto.StudyLogInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateStudyLogResponse(@Schema(description = "생성된 학습 로그 ID") Long studyLogId) { + public static CreateStudyLogResponse of(StudyLogInfo info) { + return new CreateStudyLogResponse(info.studyLogId()); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java new file mode 100644 index 0000000..6c5ab12 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java @@ -0,0 +1,63 @@ +package com.ject.studytrip.studylog.presentation.dto.response; + +import com.ject.studytrip.mission.application.dto.MissionInfo; +import com.ject.studytrip.studylog.application.dto.StudyLogDailyMissionInfo; +import com.ject.studytrip.studylog.application.dto.StudyLogDetail; +import com.ject.studytrip.studylog.application.dto.StudyLogInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.springframework.data.domain.Slice; + +public record LoadStudyLogsSliceResponse( + @Schema(description = "학습 로그 목록") List studyLogs, + @Schema(description = "다음 데이터 존재 여부") boolean hasNext) { + public static LoadStudyLogsSliceResponse of(Slice results) { + return new LoadStudyLogsSliceResponse( + results.getContent().stream() + .map( + result -> + StudyLogResponse.of( + result.studyLogInfo(), + result.studyLogDailyMissionInfos())) + .toList(), + results.hasNext()); + } + + private record StudyLogResponse( + @Schema(description = "학습 로그 ID") Long studyLogId, + @Schema(description = "학습 로그에서 선택한 미션 목록") + List dailyMissions, + @Schema(description = "학습 로그 제목") String title, + @Schema(description = "학습 로그 내용") String content, + @Schema(description = "학습 로그 생성날짜") String createdAt) { + private static StudyLogResponse of( + StudyLogInfo studyLogInfo, + List studyLogDailyMissionInfos) { + return new StudyLogResponse( + studyLogInfo.studyLogId(), + studyLogDailyMissionInfos.stream() + .map( + studyLogDailyMissionInfo -> + StudyLogDailyMissionResponse.of( + studyLogDailyMissionInfo, + studyLogDailyMissionInfo + .dailyMissionInfo() + .missionInfo())) + .toList(), + studyLogInfo.title(), + studyLogInfo.content(), + studyLogInfo.createdAt()); + } + + private record StudyLogDailyMissionResponse( + @Schema(description = "학습 로그 데일리 미션 ID") Long studyLogDailyMissionId, + @Schema(description = "미션 이름") String missionName) { + private static StudyLogDailyMissionResponse of( + StudyLogDailyMissionInfo studyLogDailyMissionInfo, MissionInfo missionInfo) { + return new StudyLogDailyMissionResponse( + studyLogDailyMissionInfo.studyLogDailyMissionId(), + missionInfo.missionName()); + } + } + } +} 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 index 25dc9ba..f2614c3 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/DailyGoalService.java @@ -7,6 +7,7 @@ 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 java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,6 +25,10 @@ public void deleteDailyGoal(DailyGoal dailyGoal) { dailyGoal.updateDeletedAt(); } + public List getCompleteDailyGoalsByTrip(Long tripId) { + return dailyGoalRepository.findAllByTripIdAndCompletedIsTrue(tripId); + } + public DailyGoal getValidDailyGoal(Long tripId, Long dailyGoalId) { DailyGoal dailyGoal = dailyGoalRepository 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 index 9d8564f..d1a663f 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/DailyGoalRepository.java @@ -1,6 +1,7 @@ package com.ject.studytrip.trip.domain.repository; import com.ject.studytrip.trip.domain.model.DailyGoal; +import java.util.List; import java.util.Optional; public interface DailyGoalRepository { @@ -8,4 +9,6 @@ public interface DailyGoalRepository { DailyGoal save(DailyGoal dailyGoal); Optional findById(Long id); + + List findAllByTripIdAndCompletedIsTrue(Long tripId); } 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 index c2e1538..6bf2916 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalJpaRepository.java @@ -1,6 +1,9 @@ package com.ject.studytrip.trip.infra.jpa; import com.ject.studytrip.trip.domain.model.DailyGoal; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface DailyGoalJpaRepository extends JpaRepository {} +public interface DailyGoalJpaRepository extends JpaRepository { + List findAllByTripIdAndCompletedIsTrue(Long tripId); +} 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 index 09f07e3..caa1bf2 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/DailyGoalRepositoryAdapter.java @@ -2,6 +2,7 @@ import com.ject.studytrip.trip.domain.model.DailyGoal; import com.ject.studytrip.trip.domain.repository.DailyGoalRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -20,4 +21,9 @@ public DailyGoal save(DailyGoal dailyGoal) { public Optional findById(Long id) { return dailyGoalJpaRepository.findById(id); } + + @Override + public List findAllByTripIdAndCompletedIsTrue(Long tripId) { + return dailyGoalJpaRepository.findAllByTripIdAndCompletedIsTrue(tripId); + } } 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 index 1e17026..03bf9fb 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/DailyMissionServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -12,6 +13,7 @@ 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.policy.DailyMissionPolicy; import com.ject.studytrip.mission.domain.repository.DailyMissionQueryRepository; import com.ject.studytrip.mission.domain.repository.DailyMissionRepository; import com.ject.studytrip.mission.fixture.DailyMissionFixture; @@ -39,6 +41,7 @@ public class DailyMissionServiceTest extends BaseUnitTest { @Mock private DailyMissionRepository dailyMissionRepository; @Mock private DailyMissionQueryRepository dailyMissionQueryRepository; + private Trip courseTrip; private DailyGoal dailyGoal; private Mission mission; private DailyMission dailyMission; @@ -46,10 +49,10 @@ public class DailyMissionServiceTest extends BaseUnitTest { @BeforeEach void setUp() { Member member = MemberFixture.createMemberFromKakaoWithId(1L); - Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - Stamp stamp = StampFixture.createStampWithId(1L, trip, 1); + courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + Stamp stamp = StampFixture.createStampWithId(1L, courseTrip, 1); mission = MissionFixture.createMissionWithId(1L, stamp, 1); - dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); dailyMission = DailyMissionFixture.createDailyMissionWithId(1L, mission, dailyGoal); } @@ -177,4 +180,60 @@ void shouldGetDailyMissionsByDailyGoalId() { assertThat(result.isEmpty()).isFalse(); } } + + @Nested + @DisplayName("validateSelectedMissions 메서드는") + class validateSelectedMissions { + + @Test + @DisplayName("코스형 여행에서 선택한 DailyMission들이 모두 동일한 스탬프를 가질 경우 예외가 발생하지 않는다") + void shouldNotThrowExceptionWhenAllStampsAreSameInCourseTrip() { + // given + Stamp stamp = StampFixture.createStampWithId(1L, courseTrip, 1); + Mission mission1 = MissionFixture.createMissionWithId(1L, stamp, 1); + Mission mission2 = MissionFixture.createMissionWithId(2L, stamp, 2); + + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + + DailyMission dailyMission1 = + DailyMissionFixture.createDailyMissionWithId(1L, mission1, dailyGoal); + DailyMission dailyMission2 = + DailyMissionFixture.createDailyMissionWithId(2L, mission2, dailyGoal); + + List dailyMissions = List.of(dailyMission1, dailyMission2); + + // when & then + assertDoesNotThrow( + () -> + DailyMissionPolicy.validateCourseTripStampConsistency( + TripCategory.COURSE, dailyMissions)); + } + + @Test + @DisplayName("코스형 여행에서 선택한 DailyMission 중 하나라도 다른 스탬프를 가지면 예외가 발생한다") + void shouldThrowExceptionWhenStampsAreDifferentInCourseTrip() { + // given + Stamp stamp1 = StampFixture.createStampWithId(1L, courseTrip, 1); + Stamp stamp2 = StampFixture.createStampWithId(2L, courseTrip, 2); + Mission mission1 = MissionFixture.createMissionWithId(1L, stamp1, 1); + Mission mission2 = MissionFixture.createMissionWithId(2L, stamp2, 1); + + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + + DailyMission dailyMission1 = + DailyMissionFixture.createDailyMissionWithId(1L, mission1, dailyGoal); + DailyMission dailyMission2 = + DailyMissionFixture.createDailyMissionWithId(2L, mission2, dailyGoal); + + List dailyMissions = List.of(dailyMission1, dailyMission2); + + // when & then + assertThatThrownBy( + () -> + DailyMissionPolicy.validateCourseTripStampConsistency( + TripCategory.COURSE, dailyMissions)) + .isInstanceOf(CustomException.class) + .hasMessage(DailyMissionErrorCode.COURSE_TRIP_STAMP_MISMATCH.getMessage()); + } + } } 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 e7cca70..e71801d 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 @@ -329,6 +329,50 @@ void shouldUpdateMissionOrders() { } } + @Nested + @DisplayName("updateCompleted 메서드는") + class updateCompleted { + + @Test + @DisplayName("정상적인 미션이면 completed 필드를 true로 업데이트하고 완료 처리한다") + void shouldUpdateCompletedMission() { + // given + Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); + + // when + missionService.updateCompleted(mission); + + // then + assertThat(mission.isCompleted()).isTrue(); + } + + @Test + @DisplayName("삭제된 미션이면 예외가 발생한다") + void shouldThrowExceptionWhenMissionIsDeleted() { + // given + Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); + ReflectionTestUtils.setField(mission, "deletedAt", LocalDateTime.now()); + + // then + assertThatThrownBy(() -> missionService.updateCompleted(mission)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("이미 완료된 미션이면 예외가 발생한다") + void shouldThrowExceptionWhenMissionIsAlreadyCompleted() { + // given + Mission mission = MissionFixture.createMissionWithId(1L, courseStamp, 1); + ReflectionTestUtils.setField(mission, "completed", true); + + // then + assertThatThrownBy(() -> missionService.updateCompleted(mission)) + .isInstanceOf(CustomException.class) + .hasMessage(MissionErrorCode.MISSION_ALREADY_COMPLETED.getMessage()); + } + } + @Nested @DisplayName("deleteMission 메서드는") class DeleteMission { diff --git a/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java b/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java index ab3597b..71f5a1c 100644 --- a/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java +++ b/src/test/java/com/ject/studytrip/mission/helper/DailyMissionTestHelper.java @@ -17,4 +17,11 @@ public DailyMission saveDailyMission(Mission mission, DailyGoal dailyGoal) { DailyMission dailyMission = DailyMissionFixture.createDailyMission(mission, dailyGoal); return dailyMissionRepository.save(dailyMission); } + + public DailyMission saveDeletedDailyMission(Mission mission, DailyGoal dailyGoal) { + DailyMission dailyMission = DailyMissionFixture.createDailyMission(mission, dailyGoal); + dailyMission.updateDeletedAt(); + + return dailyMissionRepository.save(dailyMission); + } } 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 index 528a13b..be0bd83 100644 --- a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java +++ b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroServiceTest.java @@ -125,4 +125,75 @@ void shouldThrowExceptionWhenDeletedPomodoro() { .hasMessage(PomodoroErrorCode.POMODORO_ALREADY_DELETED.getMessage()); } } + + @Nested + @DisplayName("updateTotalFocusTime 메서드는") + class UpdateTotalFocusTime { + + @Test + @DisplayName("유효한 데일리 목표 ID와 총 학습시간으로 뽀모도로의 총 학습시간을 업데이트한다") + void shouldUpdateTotalFocusTime() { + // given + int totalFocusTimeInMinutes = 120; + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.of(pomodoro)); + + // when + pomodoroService.updateTotalFocusTime(dailyGoal.getId(), totalFocusTimeInMinutes); + + // then + assertThat(pomodoro.getTotalFocusTimeInSeconds()) + .isEqualTo(totalFocusTimeInMinutes * 60); + } + + @Test + @DisplayName("뽀모도로 총 집중시간(분)이 음수일 경우 예외가 발생한다") + void shouldThrowExceptionWhenTotalFocusTimeIsNegative() { + // given + int totalFocusTimeInMinutes = -30; + + // when & then + assertThatThrownBy( + () -> + pomodoroService.updateTotalFocusTime( + dailyGoal.getId(), totalFocusTimeInMinutes)) + .isInstanceOf(CustomException.class) + .hasMessage(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME.getMessage()); + } + + @Test + @DisplayName("뽀모도로가 존재하지 않으면 예외가 발생한다") + void shouldThrowExceptionWhenPomodoroNotFound() { + // given + int totalFocusTimeInMinutes = 60; + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + pomodoroService.updateTotalFocusTime( + dailyGoal.getId(), totalFocusTimeInMinutes)) + .isInstanceOf(CustomException.class) + .hasMessage(PomodoroErrorCode.POMODORO_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("삭제된 뽀모도로일 경우 예외가 발생한다") + void shouldThrowExceptionWhenPomodoroIsDeleted() { + // given + int totalFocusTimeInMinutes = 60; + pomodoro.updateDeletedAt(); + given(pomodoroRepository.findByDailyGoalId(dailyGoal.getId())) + .willReturn(Optional.of(pomodoro)); + + // when & then + assertThatThrownBy( + () -> + pomodoroService.updateTotalFocusTime( + dailyGoal.getId(), totalFocusTimeInMinutes)) + .isInstanceOf(CustomException.class) + .hasMessage(PomodoroErrorCode.POMODORO_ALREADY_DELETED.getMessage()); + } + } } 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 6001aa6..c6ad50d 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 @@ -25,6 +25,7 @@ import com.ject.studytrip.trip.domain.model.TripCategory; import com.ject.studytrip.trip.fixture.TripFixture; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +34,7 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; @DisplayName("StampService 단위 테스트") public class StampServiceTest extends BaseUnitTest { @@ -590,4 +592,90 @@ void shouldThrowExceptionWhenNoIncompleteStampExistsForCourseTrip() { .hasMessage(StampErrorCode.STAMP_NOT_FOUND.getMessage()); } } + + @Nested + @DisplayName("getStampNameByTripCategory 메서드는") + class GetStampNameByTripCategory { + + @Test + @DisplayName("코스형 여행일 경우 선택한 미션들이 하나의 스탬프에 속하므로 해당 스탬프의 이름을 반환한다") + void shouldReturnStampNameForCourseTrip() { + // given + Stamp stamp = StampFixture.createStamp(courseTrip, 1); + List stamps = List.of(stamp); + + // when + String result = stampService.getStampNameByTripCategory(TripCategory.COURSE, stamps); + + // then + assertThat(result).isEqualTo(stamp.getName()); + } + + @Test + @DisplayName("탐험형 여행일 경우 선택한 미션들의 스탬프 중 가장 많이 포함된 스탬프의 이름을 제목으로 반환한다") + void shouldReturnMostFrequentStampNameForExplorationTrip() { + // given + Stamp stamp1 = StampFixture.createStamp(exploreTrip, 0); + Stamp stamp2 = StampFixture.createStamp(exploreTrip, 0); + List stamps = List.of(stamp1, stamp1, stamp2); + + // then + String result = stampService.getStampNameByTripCategory(TripCategory.EXPLORE, stamps); + + // when + assertThat(result).isEqualTo(stamp1.getName()); + } + + @Test + @DisplayName("탐험형 여행이면서 가장 많이 포함된 스탬프가 2개 이상일 경우 createdAt이 가장 빠른 스탬프의 이름을 반환한다") + void shouldReturnEarliestStampNameWhenFrequencyIsSame() { + // given + Stamp stamp1 = StampFixture.createStamp(exploreTrip, 0); + ReflectionTestUtils.setField(stamp1, "createdAt", LocalDateTime.now()); + + Stamp stamp2 = StampFixture.createStamp(exploreTrip, 0); + ReflectionTestUtils.setField(stamp2, "createdAt", LocalDateTime.now().minusDays(1)); + + List stamps = List.of(stamp1, stamp1, stamp2, stamp2); + + // when + String result = stampService.getStampNameByTripCategory(TripCategory.EXPLORE, stamps); + + // then + assertThat(result).isEqualTo(stamp2.getName()); + } + + @Test + @DisplayName("스탬프 목록이 비어있을 경우 예외가 발생한다") + void shouldThrowExceptionWhenStampListIsEmpty() { + // given + List emptyStamps = List.of(); + + // when & then + assertThatThrownBy( + () -> + stampService.getStampNameByTripCategory( + TripCategory.COURSE, emptyStamps)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_LIST_CANNOT_BE_EMPTY.getMessage()); + } + + @Test + @DisplayName("스탬프가 삭제된 상태일 경우 예외가 발생한다") + void shouldThrowExceptionWhenStampIsDeleted() { + // given + Stamp stamp = StampFixture.createStamp(courseTrip, 1); + ReflectionTestUtils.setField(stamp, "deletedAt", LocalDateTime.now()); + + List stamps = List.of(stamp); + + // when & then + assertThatThrownBy( + () -> + stampService.getStampNameByTripCategory( + TripCategory.COURSE, stamps)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_ALREADY_DELETED.getMessage()); + } + } } diff --git a/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java index fa68b08..7ce0860 100644 --- a/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.java @@ -34,6 +34,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; +@DisplayName("StampController 통합 테스트") public class StampControllerIntegrationTest extends BaseIntegrationTest { private static final int NEXT_STAMP_ORDER = 3; diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionServiceTest.java new file mode 100644 index 0000000..1739cb9 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionServiceTest.java @@ -0,0 +1,123 @@ +package com.ject.studytrip.studylog.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.mission.domain.model.Mission; +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.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionQueryRepository; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionRepository; +import com.ject.studytrip.studylog.fixture.StudyLogFixture; +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.HashMap; +import java.util.List; +import java.util.Map; +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("StudyLogDailyMissionService 단위 테스트") +public class StudyLogDailyMissionServiceTest extends BaseUnitTest { + @InjectMocks private StudyLogDailyMissionService studyLogDailyMissionService; + @Mock private StudyLogDailyMissionRepository studyLogDailyMissionRepository; + @Mock private StudyLogDailyMissionQueryRepository studyLogDailyMissionQueryRepository; + + private Member member; + private Mission mission1; + private Mission mission2; + private DailyGoal dailyGoal; + + @BeforeEach + void setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + Stamp stamp = StampFixture.createStampWithId(1L, trip, 1); + mission1 = MissionFixture.createMissionWithId(1L, stamp, 1); + mission2 = MissionFixture.createMissionWithId(2L, stamp, 2); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); + } + + @Nested + @DisplayName("createStudyLogDailyMissions 메서드는") + class createStudyLogDailyMissions { + + @Test + @DisplayName("학습 로그와 데일리 미션 목록으로 학습 로그 데일리 미션을 생성하여 저장하고 반환한다") + void shouldReturnCreateStudyLogDailyMissions() { + // given + StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + + DailyMission dailyMission1 = + DailyMissionFixture.createDailyMissionWithId(1L, mission1, dailyGoal); + DailyMission dailyMission2 = + DailyMissionFixture.createDailyMissionWithId(2L, mission2, dailyGoal); + List dailyMissions = List.of(dailyMission1, dailyMission2); + + given(studyLogDailyMissionRepository.saveAll(anyList())) + .willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = + studyLogDailyMissionService.createStudyLogDailyMissions( + studyLog, dailyMissions); + + // then + assertThat(result.size()).isEqualTo(dailyMissions.size()); + } + } + + @Nested + @DisplayName("getGroupedStudyLogDailyMissionsByStudyLogIds 메서드는") + class getGroupedStudyLogDailyMissionsByStudyLogIds { + + @Test + @DisplayName("학습 로그 ID 리스트로 그룹화된 StudyLogDailyMission Map을 반환한다") + void shouldReturnGroupedStudyLogDailyMissionMap() { + // given + Long studyLogId1 = 1L; + Long studyLogId2 = 2L; + + StudyLogDailyMission studyLogDailyMission1 = mock(StudyLogDailyMission.class); + StudyLogDailyMission studyLogDailyMission2 = mock(StudyLogDailyMission.class); + StudyLogDailyMission studyLogDailyMission3 = mock(StudyLogDailyMission.class); + + Map> mockResult = new HashMap<>(); + mockResult.put(studyLogId1, List.of(studyLogDailyMission1, studyLogDailyMission2)); + mockResult.put(studyLogId2, List.of(studyLogDailyMission3)); + + List studyLogIds = List.of(studyLogId1, studyLogId2); + + given( + studyLogDailyMissionQueryRepository + .findStudyLogDailyMissionsGroupedByStudyLogId(studyLogIds)) + .willReturn(mockResult); + + // when + Map> result = + studyLogDailyMissionService.getGroupedStudyLogDailyMissionsByStudyLogIds( + studyLogIds); + + // then + assertThat(result).isEqualTo(mockResult); + verify(studyLogDailyMissionQueryRepository, times(1)) + .findStudyLogDailyMissionsGroupedByStudyLogId(studyLogIds); + } + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java index 881e648..4d046bf 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java @@ -1,29 +1,48 @@ package com.ject.studytrip.studylog.application.service; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import com.ject.studytrip.BaseUnitTest; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import com.ject.studytrip.studylog.fixture.StudyLogFixture; +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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.test.util.ReflectionTestUtils; +@DisplayName("StudyLogService 단위 테스트") class StudyLogServiceTest extends BaseUnitTest { @InjectMocks private StudyLogService studyLogService; + @Mock private StudyLogRepository studyLogRepository; @Mock private StudyLogQueryRepository studyLogQueryRepository; private Member member; + private Trip courseTrip; @BeforeEach void setUp() { member = MemberFixture.createMemberFromKakaoWithId(1L); + courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); } @Nested @@ -58,4 +77,72 @@ void shouldReturnCountWhenStudyLogExistsForMember() { assertThat(result).isEqualTo(3L); } } + + @Nested + @DisplayName("createStudyLog 메서드는") + class createStudyLog { + + @Test + @DisplayName("학습 로그를 생성해 저장하고 반환한다") + void shouldReturnCreateStudyLog() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + String title = "TEST Title"; + String content = "TEST content"; + + given(studyLogRepository.save(any())) + .willAnswer( + invocation -> { + StudyLog studyLog = invocation.getArgument(0); + ReflectionTestUtils.setField(studyLog, "id", 1L); + return studyLog; + }); + + // when + StudyLog result = studyLogService.createStudyLog(member, dailyGoal, title, content); + + // then + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getMember()).isEqualTo(member); + assertThat(result.getDailyGoal()).isEqualTo(dailyGoal); + assertThat(result.getTitle()).isEqualTo(title); + assertThat(result.getContent()).isEqualTo(content); + } + } + + @Nested + @DisplayName("getStudyLogsSliceByTripId 메서드는") + class getStudyLogsSliceByTripId { + + @Test + @DisplayName("특정 여행의 학습 로그 목록을 페이징 처리와 최신순으로 정렬하고 반환한다") + void shouldReturnStudyLogsByTripIdWithSlice() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + + StudyLog studyLog1 = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + StudyLog studyLog2 = StudyLogFixture.createStudyLogWithId(2L, member, dailyGoal); + List studyLogs = List.of(studyLog1, studyLog2); + + int page = 0; + int size = 5; + Pageable pageable = PageRequest.of(page, size); + + Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); + + given( + studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc( + courseTrip.getId(), pageable)) + .willReturn(mockSlice); + + // when + Slice result = + studyLogService.getStudyLogsSliceByTripId(courseTrip.getId(), page, size); + + // then + assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); + assertThat(result.getContent().get(0)).isEqualTo(studyLog1); + assertThat(result.getContent().get(1)).isEqualTo(studyLog2); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java b/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java new file mode 100644 index 0000000..ae78ee4 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java @@ -0,0 +1,29 @@ +package com.ject.studytrip.studylog.fixture; + +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +import java.util.List; + +public class CreateStudyLogRequestFixture { + private int totalFocusTimeInMinutes = 75; + private List selectedDailyMissionIds = List.of(1L); + private String content = "TEST 학습 로그 내용"; + + public CreateStudyLogRequestFixture withTotalFocusTimeInMinutes(int totalFocusTimeInMinutes) { + this.totalFocusTimeInMinutes = totalFocusTimeInMinutes; + return this; + } + + public CreateStudyLogRequestFixture withSelectedDailyMissionIds(List ids) { + this.selectedDailyMissionIds = ids; + return this; + } + + public CreateStudyLogRequestFixture withContent(String content) { + this.content = content; + return this; + } + + public CreateStudyLogRequest build() { + return new CreateStudyLogRequest(totalFocusTimeInMinutes, selectedDailyMissionIds, content); + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogDailyMissionFixture.java b/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogDailyMissionFixture.java new file mode 100644 index 0000000..d532d39 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogDailyMissionFixture.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.studylog.fixture; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.studylog.domain.factory.StudyLogDailyMissionFactory; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import org.springframework.test.util.ReflectionTestUtils; + +public class StudyLogDailyMissionFixture { + public static StudyLogDailyMission createStudyLogDailyMission( + StudyLog studyLog, DailyMission dailyMission) { + return StudyLogDailyMissionFactory.create(studyLog, dailyMission); + } + + public static StudyLogDailyMission createStudyLogDailyMissionWithId( + Long id, StudyLog studyLog, DailyMission dailyMission) { + StudyLogDailyMission studyLogDailyMission = + createStudyLogDailyMission(studyLog, dailyMission); + ReflectionTestUtils.setField(studyLogDailyMission, "id", id); + + return studyLogDailyMission; + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java b/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java new file mode 100644 index 0000000..f8671cd --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.studylog.fixture; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.studylog.domain.factory.StudyLogFactory; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.test.util.ReflectionTestUtils; + +public class StudyLogFixture { + private static final String STUDY_LOG_TITLE = "TEST 학습 로그 제목"; + private static final String STUDY_LOG_CONTENT = "TEST 학습 로그 내용"; + + public static StudyLog createStudyLog(Member member, DailyGoal dailyGoal) { + return StudyLogFactory.create(member, dailyGoal, STUDY_LOG_TITLE, STUDY_LOG_CONTENT); + } + + public static StudyLog createStudyLogWithId(Long id, Member member, DailyGoal dailyGoal) { + StudyLog studyLog = createStudyLog(member, dailyGoal); + ReflectionTestUtils.setField(studyLog, "id", id); + + return studyLog; + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/helper/StudyLogDailyMissionTestHelper.java b/src/test/java/com/ject/studytrip/studylog/helper/StudyLogDailyMissionTestHelper.java new file mode 100644 index 0000000..45f63c4 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/helper/StudyLogDailyMissionTestHelper.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.studylog.helper; + +import com.ject.studytrip.mission.domain.model.DailyMission; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.domain.repository.StudyLogDailyMissionRepository; +import com.ject.studytrip.studylog.fixture.StudyLogDailyMissionFixture; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class StudyLogDailyMissionTestHelper { + @Autowired private StudyLogDailyMissionRepository studyLogDailyMissionRepository; + + public List saveStudyLogDailyMissions( + StudyLog studyLog, DailyMission dailyMission) { + StudyLogDailyMission studyLogDailyMission = + StudyLogDailyMissionFixture.createStudyLogDailyMission(studyLog, dailyMission); + return studyLogDailyMissionRepository.saveAll(List.of(studyLogDailyMission)); + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java b/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java new file mode 100644 index 0000000..856a092 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java @@ -0,0 +1,19 @@ +package com.ject.studytrip.studylog.helper; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import com.ject.studytrip.studylog.fixture.StudyLogFixture; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class StudyLogTestHelper { + @Autowired private StudyLogRepository studyLogRepository; + + public StudyLog saveStudyLog(Member member, DailyGoal dailyGoal) { + StudyLog studyLog = StudyLogFixture.createStudyLog(member, dailyGoal); + return studyLogRepository.save(studyLog); + } +} diff --git a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java new file mode 100644 index 0000000..a259b9e --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java @@ -0,0 +1,763 @@ +package com.ject.studytrip.studylog.presentation.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.ject.studytrip.BaseIntegrationTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.fixture.TokenFixture; +import com.ject.studytrip.auth.helper.TokenTestHelper; +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.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.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.fixture.CreateStudyLogRequestFixture; +import com.ject.studytrip.studylog.helper.StudyLogDailyMissionTestHelper; +import com.ject.studytrip.studylog.helper.StudyLogTestHelper; +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +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.helper.DailyGoalTestHelper; +import com.ject.studytrip.trip.helper.TripTestHelper; +import java.util.List; +import org.apache.http.HttpHeaders; +import org.hamcrest.Matchers; +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.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("StudyLogController 통합 테스트") +public class StudyLogControllerIntegrationTest extends BaseIntegrationTest { + @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TokenTestHelper tokenTestHelper; + @Autowired private TripTestHelper tripTestHelper; + @Autowired private StampTestHelper stampTestHelper; + @Autowired private MissionTestHelper missionTestHelper; + @Autowired private DailyGoalTestHelper dailyGoalTestHelper; + @Autowired private DailyMissionTestHelper dailyMissionTestHelper; + @Autowired private StudyLogTestHelper studyLogTestHelper; + @Autowired private StudyLogDailyMissionTestHelper studyLogDailyMissionTestHelper; + @Autowired private PomodoroTestHelper pomodoroTestHelper; + + private Member member; + private String token; + private Trip courseTrip; + private Stamp stamp; + private Mission mission; + private DailyGoal dailyGoal; + private DailyMission dailyMission; + private Pomodoro pomodoro; + + @BeforeEach + void setUp() { + member = memberTestHelper.saveMember(); + token = + tokenTestHelper.createAccessToken( + member.getId().toString(), MemberRole.ROLE_USER.name()); + courseTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + stamp = stampTestHelper.saveStamp(courseTrip, 1); + mission = missionTestHelper.saveMission(stamp, 1); + dailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); + dailyMission = dailyMissionTestHelper.saveDailyMission(mission, dailyGoal); + pomodoro = pomodoroTestHelper.savePomodoro(dailyGoal); + } + + @Nested + @DisplayName("학습 로그 생성 API") + class CreateStudyLog { + private final CreateStudyLogRequestFixture fixture = new CreateStudyLogRequestFixture(); + + private ResultActions getResultActions( + String token, Object tripId, Object dailyGoalId, CreateStudyLogRequest request) + throws Exception { + return mockMvc.perform( + post( + "/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs", + tripId, + dailyGoalId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("유효한 요청으로 학습 로그를 생성한다") + void shouldCreateStudyLog() throws Exception { + // given + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogId").isNumber()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // given + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions("", courseTrip.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"; + CreateStudyLogRequest 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"; + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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("학습 로그를 생성하는데 필요한 필수 요청 값이 누락되거나 유효하지 않으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenInvalidRequiredFields() throws Exception { + // given + CreateStudyLogRequest request = + fixture.withTotalFocusTimeInMinutes(-30) + .withSelectedDailyMissionIds(List.of()) + .build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), dailyGoal.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; + CreateStudyLogRequest 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); + CreateStudyLogRequest 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); + CreateStudyLogRequest 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; + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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); + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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(courseTrip); + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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("선택한 데일리 미션 목록과 조회된 데일리 미션 목록이 일치하지 않으면 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenAlreadyDailyMissions() throws Exception { + // given + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId(), 1000L)) + .build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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 shouldReturnForbiddenWhenDailyMissionNotBelongToDailyGoal() throws Exception { + // given + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), newDailyGoal.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 shouldReturnBadRequestWhenAlreadyDailyMission() throws Exception { + // given + DailyMission deleted = + dailyMissionTestHelper.saveDeletedDailyMission(mission, dailyGoal); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(deleted.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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("코스형 여행에서 선택된 데일리 미션들이 각각 속한 스탬프가 다를 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenSelectedDailyMissionsHaveDifferentStampsInCourseTrip() + throws Exception { + // given + Stamp newStamp = stampTestHelper.saveStamp(courseTrip, 2); + Mission newMission = missionTestHelper.saveMission(newStamp, 1); + DailyMission newDailyMission = + dailyMissionTestHelper.saveDailyMission(newMission, dailyGoal); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds( + List.of(dailyMission.getId(), newDailyMission.getId())) + .build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + DailyMissionErrorCode.COURSE_TRIP_STAMP_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("삭제된 스탬프가 포함되어있을 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenStampIsDeleted() throws Exception { + // given + Trip trip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + Stamp deletedStamp = stampTestHelper.saveDeletedStamp(trip, 1); + Mission mission = missionTestHelper.saveMission(deletedStamp, 1); + DailyGoal dailyGoal = dailyGoalTestHelper.saveDailyGoal(trip); + DailyMission dailyMission = dailyMissionTestHelper.saveDailyMission(mission, dailyGoal); + + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(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( + StampErrorCode.STAMP_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("요청한 DailyGoalId의 뽀모도로가 존재하지 않을 경우 404 NotFound를 반환한다") + void shouldReturnNotFoundWhenPomodoroNotFound() throws Exception { + // given + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); + CreateStudyLogRequest request = fixture.build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_NOT_FOUND + .getStatus() + .value())); + } + + @Test + @DisplayName("요청한 DailyGoalId의 뽀모도로가 삭제된 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenPomodoroIsDeleted() throws Exception { + // given + DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); + DailyMission newDailyMission = + dailyMissionTestHelper.saveDailyMission(mission, newDailyGoal); + pomodoroTestHelper.saveDeletedPomodoro(newDailyGoal); + + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + PomodoroErrorCode.POMODORO_ALREADY_DELETED + .getStatus() + .value())); + } + + @Test + @DisplayName("선택된 미션들을 완료 처리할 때, 이미 삭제된 미션일 경우 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenSelectedMissionIsAlreadyDeleted() throws Exception { + // given + Mission deletedMission = missionTestHelper.saveDeletedMission(stamp, 2); + DailyMission newDailyMission = + dailyMissionTestHelper.saveDailyMission(deletedMission, dailyGoal); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.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 shouldReturnBadRequestWhenSelectedMissionIsAlreadyCompleted() throws Exception { + // given + Mission deletedMission = missionTestHelper.saveCompletedMission(stamp, 2); + DailyMission newDailyMission = + dailyMissionTestHelper.saveDailyMission(deletedMission, dailyGoal); + CreateStudyLogRequest request = + fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MissionErrorCode.MISSION_ALREADY_COMPLETED + .getStatus() + .value())); + } + } + + @Nested + @DisplayName("학습 로그 목록 조회 API") + class ListStudyLogs { + private static final String DEFAULT_PAGE = "0"; + private static final String DEFAULT_PAGE_SIZE = "5"; + + private ResultActions getResultActions( + String token, Object tripId, String page, String size) throws Exception { + return mockMvc.perform( + get("/api/trips/{tripId}/study-logs", tripId) + .param("page", page) + .param("size", size) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("특정 여행의 학습 로그 목록을 조회하고 슬라이스 처리해 반환한다") + void shouldLoadStudyLogsByTripWithSlicePaging() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + List studyLogDailyMissions = + studyLogDailyMissionTestHelper.saveStudyLogDailyMissions( + studyLog, dailyMission); + + // when + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogs").isNotEmpty()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.getId())) + .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty()) + .andExpect( + jsonPath("$.data.studyLogs[0].dailyMissions") + .value(Matchers.hasSize(studyLogDailyMissions.size()))); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = + getResultActions("", courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // 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, DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("Request Param 페이징 데이터 타입이 올바르지 않으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenWhenPagingParameterTypeMismatch() throws Exception { + // Given + String page = "test"; + String size = "test"; + + // when + ResultActions resultActions = getResultActions(token, courseTrip, page, size); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())); + } + + @Test + @DisplayName("Request Param 페이징 데이터가 유효하지 않으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenWhenPagingParameterIsInvalid() throws Exception { + // Given + String page = "-1"; + String size = "100"; + + // when + ResultActions resultActions = getResultActions(token, courseTrip.getId(), page, size); + + // 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; + + // when + ResultActions resultActions = + getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // 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(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // 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(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + } +}