diff --git a/build.gradle b/build.gradle index 580edd5..adce32d 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' id 'com.diffplug.spotless' version '6.21.0' id 'org.flywaydb.flyway' version '11.11.2' + id 'org.jetbrains.kotlin.jvm' } group = 'com.ject' @@ -77,6 +78,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.mockito:mockito-core' testImplementation 'org.mockito:mockito-junit-jupiter' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } jar.enabled = false // 일반 JAR 파일 생성 비활성화 diff --git a/settings.gradle b/settings.gradle index 57f163d..ee112ff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,6 @@ +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.jvm' version '2.2.20' + } +} rootProject.name = 'studytrip-server' diff --git a/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java b/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java index c0f09d1..a8ce875 100644 --- a/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java +++ b/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java @@ -1,10 +1,10 @@ package com.ject.studytrip.member.application.dto; -import com.ject.studytrip.trip.application.dto.TripCountInfo; +import com.ject.studytrip.trip.application.dto.TripCount; -public record MemberDetail(MemberInfo memberInfo, TripCountInfo tripCount, long studyLogCount) { +public record MemberDetail(MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { public static MemberDetail from( - MemberInfo memberInfo, TripCountInfo tripCount, long studyLogCount) { + MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { return new MemberDetail(memberInfo, tripCount, studyLogCount); } } diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java index 125dd88..9d78d25 100644 --- a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java +++ b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java @@ -14,7 +14,7 @@ import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import com.ject.studytrip.studylog.application.service.StudyLogQueryService; -import com.ject.studytrip.trip.application.dto.TripCountInfo; +import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.application.service.TripQueryService; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; @@ -62,7 +62,7 @@ public void deleteMember(Long memberId) { @Transactional(readOnly = true) public MemberDetail getMemberDetail(Long memberId) { Member member = memberQueryService.getValidMember(memberId); - TripCountInfo tripCount = tripQueryService.getActiveTripCountsByMemberId(memberId); + TripCount tripCount = tripQueryService.getActiveTripCountsByMemberId(memberId); long studyLogCount = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId); MemberInfo memberInfo = MemberInfo.from(member); diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java b/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java index 361960c..bb72fb5 100644 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java @@ -2,7 +2,7 @@ import com.ject.studytrip.member.application.dto.MemberInfo; import com.ject.studytrip.member.domain.model.MemberCategory; -import com.ject.studytrip.trip.application.dto.TripCountInfo; +import com.ject.studytrip.trip.application.dto.TripCount; import io.swagger.v3.oas.annotations.media.Schema; public record LoadMemberDetailResponse( @@ -15,7 +15,7 @@ public record LoadMemberDetailResponse( @Schema(description = "탐험형 여행 개수") long exploreTripCount, @Schema(description = "학습 기록 개수") long studyLogCount) { public static LoadMemberDetailResponse of( - MemberInfo memberInfo, TripCountInfo tripCount, long studyLogCount) { + MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { return new LoadMemberDetailResponse( memberInfo.memberId(), memberInfo.email(), diff --git a/src/main/java/com/ject/studytrip/mission/application/service/MissionQueryService.java b/src/main/java/com/ject/studytrip/mission/application/service/MissionQueryService.java index d7c7bec..bbbf1d5 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/MissionQueryService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/MissionQueryService.java @@ -44,4 +44,8 @@ public List getValidMissionsWithStamp(List missionIds) { return missions; } + + public long countCompletedMissionsByTripId(Long tripId) { + return missionQueryRepository.countCompletedMissionsByTripId(tripId); + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java index 93da4aa..e31e577 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java +++ b/src/main/java/com/ject/studytrip/mission/domain/repository/MissionQueryRepository.java @@ -11,4 +11,6 @@ public interface MissionQueryRepository { long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedStampOwner(); + + long countCompletedMissionsByTripId(Long tripId); } diff --git a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java index cc3ef54..0371a89 100644 --- a/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/mission/infra/querydsl/MissionQueryRepositoryAdapter.java @@ -4,6 +4,7 @@ import com.ject.studytrip.mission.domain.model.QMission; import com.ject.studytrip.mission.domain.repository.MissionQueryRepository; import com.ject.studytrip.stamp.domain.model.QStamp; +import com.ject.studytrip.trip.domain.model.QTrip; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -16,6 +17,7 @@ public class MissionQueryRepositoryAdapter implements MissionQueryRepository { private final JPAQueryFactory queryFactory; private final QMission mission = QMission.mission; private final QStamp stamp = QStamp.stamp; + private final QTrip trip = QTrip.trip; @Override public List findAllByIdsInFetchJoinStamp(List ids) { @@ -59,6 +61,25 @@ public long deleteAllByDeletedStampOwner() { .execute(); } + @Override + public long countCompletedMissionsByTripId(Long tripId) { + Long count = + queryFactory + .select(mission.id.count()) + .from(mission) + .join(mission.stamp, stamp) + .join(stamp.trip, trip) + .where( + trip.id.eq(tripId), + mission.completed.isTrue(), + mission.deletedAt.isNull(), + stamp.deletedAt.isNull(), + trip.deletedAt.isNull()) + .fetchOne(); + + return count != null ? count : 0L; + } + // @Override // public long countByStampIdAndDeletedAtIsNull(Long stampId) { // Long count = diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.java b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.java index 26467af..112e1f7 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.java +++ b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.java @@ -4,6 +4,7 @@ import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; import com.ject.studytrip.pomodoro.domain.model.Pomodoro; import com.ject.studytrip.pomodoro.domain.policy.PomodoroPolicy; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroQueryRepository; import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,6 +13,7 @@ @RequiredArgsConstructor public class PomodoroQueryService { private final PomodoroRepository pomodoroRepository; + private final PomodoroQueryRepository pomodoroQueryRepository; public Pomodoro getValidPomodoroByDailyGoal(Long dailyGoalId) { Pomodoro pomodoro = @@ -24,4 +26,8 @@ public Pomodoro getValidPomodoroByDailyGoal(Long dailyGoalId) { return pomodoro; } + + public long getTotalFocusHoursByTripId(Long tripId) { + return pomodoroQueryRepository.sumFocusHoursByTripId(tripId); + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java index 9f28301..31c0b69 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroQueryRepository.java @@ -4,4 +4,6 @@ public interface PomodoroQueryRepository { long deleteAllByDeletedAtIsNotNull(); long deleteAllByDeletedDailyGoalOwner(); + + long sumFocusHoursByTripId(Long tripId); } diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java index 1cb9447..2eb65ef 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/pomodoro/infra/querydsl/PomodoroQueryRepositoryAdapter.java @@ -3,6 +3,7 @@ import com.ject.studytrip.pomodoro.domain.model.QPomodoro; import com.ject.studytrip.pomodoro.domain.repository.PomodoroQueryRepository; import com.ject.studytrip.trip.domain.model.QDailyGoal; +import com.ject.studytrip.trip.domain.model.QTrip; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -14,6 +15,7 @@ public class PomodoroQueryRepositoryAdapter implements PomodoroQueryRepository { private final JPAQueryFactory queryFactory; private final QPomodoro pomodoro = QPomodoro.pomodoro; private final QDailyGoal dailyGoal = QDailyGoal.dailyGoal; + private final QTrip trip = QTrip.trip; @Override public long deleteAllByDeletedAtIsNotNull() { @@ -31,4 +33,24 @@ public long deleteAllByDeletedDailyGoalOwner() { .where(dailyGoal.deletedAt.isNotNull()))) .execute(); } + + @Override + public long sumFocusHoursByTripId(Long tripId) { + Integer totalSeconds = + queryFactory + .select(pomodoro.totalFocusTimeInSeconds.sum()) + .from(pomodoro) + .join(pomodoro.dailyGoal, dailyGoal) + .join(dailyGoal.trip, trip) + .where( + trip.id.eq(tripId), + pomodoro.deletedAt.isNull(), + dailyGoal.deletedAt.isNull(), + trip.deletedAt.isNull()) + .fetchOne(); + + long seconds = totalSeconds == null ? 0L : totalSeconds.longValue(); + + return seconds / 3600L; // 정수 시간(내림) + } } 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 index 113066b..2940129 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java @@ -2,14 +2,19 @@ import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import java.util.Collections; import java.util.List; +import java.util.Optional; public record StudyLogDetail( StudyLogInfo studyLogInfo, List studyLogDailyMissionInfos) { public static StudyLogDetail from( StudyLog studyLog, List studyLogDailyMissions) { + List safeStudyLogDailyMissions = + Optional.ofNullable(studyLogDailyMissions).orElse(Collections.emptyList()); + return new StudyLogDetail( StudyLogInfo.from(studyLog), - studyLogDailyMissions.stream().map(StudyLogDailyMissionInfo::from).toList()); + safeStudyLogDailyMissions.stream().map(StudyLogDailyMissionInfo::from).toList()); } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java index dca201c..42fb7f7 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java @@ -6,6 +6,7 @@ import com.ject.studytrip.studylog.domain.policy.StudyLogPolicy; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; @@ -37,4 +38,18 @@ public StudyLog getValidStudyLog(Long studyLogId) { return studyLog; } + + public List getValidStudyLogs(List studyLogIds) { + List studyLogs = studyLogRepository.findAllByIdIn(studyLogIds); + + StudyLogPolicy.validateExistAll(studyLogs, studyLogIds); + studyLogs.forEach(StudyLogPolicy::validateNotDeleted); + + return studyLogs; + } + + public Slice getStudyLogsSliceByTripReportId(Long tripReportId, int page, int size) { + return studyLogQueryRepository.findSliceByTripReportIdOrderByCreatedAtDesc( + tripReportId, PageRequest.of(page, size)); + } } 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 index 06f7a47..17005fe 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java @@ -3,6 +3,7 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; import com.ject.studytrip.studylog.domain.model.StudyLog; +import java.util.List; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -13,4 +14,11 @@ public static void validateNotDeleted(StudyLog studyLog) { throw new CustomException(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED); } } + + public static void validateExistAll(List foundStudyLogs, List requestedIds) { + boolean isEquals = foundStudyLogs.size() == requestedIds.size(); + if (!isEquals) { + throw new CustomException(StudyLogErrorCode.STUDY_LOG_NOT_FOUND); + } + } } 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 3753430..bfd47c2 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 @@ -14,4 +14,7 @@ public interface StudyLogQueryRepository { long deleteAllByDeletedMemberOwner(); long deleteAllByDeletedDailyGoalOwner(); + + Slice findSliceByTripReportIdOrderByCreatedAtDesc( + Long tripReportId, 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 index 7938e93..4991c88 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java @@ -1,6 +1,8 @@ package com.ject.studytrip.studylog.domain.repository; import com.ject.studytrip.studylog.domain.model.StudyLog; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface StudyLogRepository { @@ -8,4 +10,6 @@ public interface StudyLogRepository { StudyLog save(StudyLog studyLog); Optional findById(Long studyLogId); + + List findAllByIdIn(Collection studyLogIds); } 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 index 6f3c12d..4d203f1 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java @@ -1,6 +1,10 @@ package com.ject.studytrip.studylog.infra.jpa; import com.ject.studytrip.studylog.domain.model.StudyLog; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudyLogJpaRepository extends JpaRepository {} +public interface StudyLogJpaRepository extends JpaRepository { + List findAllByIdIn(Collection studyLogIds); +} 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 index a643a33..e396e36 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java @@ -2,6 +2,8 @@ import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -20,4 +22,9 @@ public StudyLog save(StudyLog studyLog) { public Optional findById(Long studyLogId) { return studyLogJpaRepository.findById(studyLogId); } + + @Override + public List findAllByIdIn(Collection studyLogIds) { + return studyLogJpaRepository.findAllByIdIn(studyLogIds); + } } 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 570d971..5d465bc 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 @@ -5,6 +5,7 @@ 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.ject.studytrip.trip.domain.model.QTripReportStudyLog; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -22,6 +23,7 @@ public class StudyLogQueryRepositoryAdapter implements StudyLogQueryRepository { private final QStudyLog studyLog = QStudyLog.studyLog; private final QDailyGoal dailyGoal = QDailyGoal.dailyGoal; private final QMember member = QMember.member; + private final QTripReportStudyLog tripReportStudyLog = QTripReportStudyLog.tripReportStudyLog; @Override public long countActiveStudyLogsByMemberId(Long memberId) { @@ -84,4 +86,26 @@ public long deleteAllByDeletedDailyGoalOwner() { .where(dailyGoal.deletedAt.isNotNull()))) .execute(); } + + @Override + public Slice findSliceByTripReportIdOrderByCreatedAtDesc( + Long tripReportId, Pageable pageable) { + List content = + queryFactory + .select(studyLog) + .from(tripReportStudyLog) + .join(tripReportStudyLog.studyLog, studyLog) + .where( + tripReportStudyLog.tripReport.id.eq(tripReportId), + studyLog.deletedAt.isNull()) + .orderBy(studyLog.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = content.size() > pageable.getPageSize(); + List result = hasNext ? content.subList(0, pageable.getPageSize()) : content; + + return new SliceImpl<>(result, pageable, hasNext); + } } diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/PresignedTripReportImageInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/PresignedTripReportImageInfo.java new file mode 100644 index 0000000..3b7a2df --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/PresignedTripReportImageInfo.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.trip.application.dto; + +public record PresignedTripReportImageInfo(Long tripReportId, String tmpKey, String presignedUrl) { + public static PresignedTripReportImageInfo of( + Long tripReportId, String tmpKey, String presignedUrl) { + return new PresignedTripReportImageInfo(tripReportId, tmpKey, presignedUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java new file mode 100644 index 0000000..c5d5082 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.trip.application.dto; + +public record TripCount(long course, long explore) { + public static TripCount of(long course, long explore) { + return new TripCount(course, explore); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripCountInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripCountInfo.java deleted file mode 100644 index b0b4642..0000000 --- a/src/main/java/com/ject/studytrip/trip/application/dto/TripCountInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ject.studytrip.trip.application.dto; - -public record TripCountInfo(long course, long explore) { - public static TripCountInfo of(long course, long explore) { - return new TripCountInfo(course, explore); - } -} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripReportDetail.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportDetail.java new file mode 100644 index 0000000..d322b0c --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportDetail.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; + +public record TripReportDetail(TripReportInfo tripReportInfo, StudyLogSliceInfo studyLogSliceInfo) { + public static TripReportDetail from( + TripReportInfo tripReportInfo, StudyLogSliceInfo studyLogSliceInfo) { + return new TripReportDetail(tripReportInfo, studyLogSliceInfo); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripReportInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportInfo.java new file mode 100644 index 0000000..acaa90e --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportInfo.java @@ -0,0 +1,36 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.trip.domain.model.TripReport; + +public record TripReportInfo( + Long tripReportId, + String title, + String content, + String startDate, + String endDate, + long completedMissionCount, + long totalFocusHours, + long studyDays, + String imageTitle, + String imageUrl, + String createdAt, + String updatedAt, + String deletedAt) { + public static TripReportInfo from(TripReport tripReport) { + return new TripReportInfo( + tripReport.getId(), + tripReport.getTitle(), + tripReport.getContent(), + tripReport.getStartDate(), + tripReport.getEndDate(), + tripReport.getCompletedMissionCount(), + tripReport.getTotalFocusHours(), + tripReport.getStudyDays(), + tripReport.getImageTitle(), + tripReport.getImageUrl(), + DateUtil.formatDateTime(tripReport.getCreatedAt()), + DateUtil.formatDateTime(tripReport.getUpdatedAt()), + DateUtil.formatDateTime(tripReport.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripReportsInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportsInfo.java new file mode 100644 index 0000000..64582a0 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripReportsInfo.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.application.dto; + +import java.util.List; + +public record TripReportsInfo(List tripReportInfos) { + public static TripReportsInfo of(List tripReportInfos) { + return new TripReportsInfo(tripReportInfos); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectDetail.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectDetail.java new file mode 100644 index 0000000..3c7ed36 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectDetail.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; + +public record TripRetrospectDetail( + TripRetrospectSummary summary, TripInfo tripInfo, StudyLogSliceInfo studyLogDetailSlice) { + public static TripRetrospectDetail from( + TripRetrospectSummary summary, + TripInfo tripInfo, + StudyLogSliceInfo studyLogDetailSlice) { + return new TripRetrospectDetail(summary, tripInfo, studyLogDetailSlice); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectSummary.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectSummary.java new file mode 100644 index 0000000..4376b0e --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripRetrospectSummary.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.trip.application.dto; + +public record TripRetrospectSummary( + long completedMissionCount, // 완료 미션 수 + long totalFocusHours, // 총 집중 시간(시간 단위) + long studyDays // 학습한 일수(중복 날짜 제거) + ) { + public static TripRetrospectSummary of( + long completedMissionCount, long totalFocusHours, long studyDays) { + return new TripRetrospectSummary(completedMissionCount, totalFocusHours, studyDays); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java b/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java new file mode 100644 index 0000000..6d2b8da --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java @@ -0,0 +1,152 @@ +package com.ject.studytrip.trip.application.facade; + +import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.application.service.ImageService; +import com.ject.studytrip.member.application.service.MemberQueryService; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.mission.application.service.MissionQueryService; +import com.ject.studytrip.pomodoro.application.service.PomodoroQueryService; +import com.ject.studytrip.studylog.application.dto.StudyLogDetail; +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionQueryService; +import com.ject.studytrip.studylog.application.service.StudyLogQueryService; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.trip.application.dto.*; +import com.ject.studytrip.trip.application.service.TripQueryService; +import com.ject.studytrip.trip.application.service.TripReportCommandService; +import com.ject.studytrip.trip.application.service.TripReportQueryService; +import com.ject.studytrip.trip.application.service.TripReportStudyLogCommandService; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.presentation.dto.request.ConfirmTripReportImageRequest; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +import com.ject.studytrip.trip.presentation.dto.request.PresignTripReportImageRequest; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class TripReportFacade { + private static final String TRIP_REPORT_IMAGE_KEY_PREFIX = "trip-reports"; + + private final MemberQueryService memberQueryService; + private final TripQueryService tripQueryService; + private final MissionQueryService missionQueryService; + private final StudyLogQueryService studyLogQueryService; + private final StudyLogDailyMissionQueryService studyLogDailyMissionQueryService; + private final PomodoroQueryService pomodoroQueryService; + private final TripReportQueryService tripReportQueryService; + + private final TripReportCommandService tripReportCommandService; + private final TripReportStudyLogCommandService tripReportStudyLogCommandService; + + private final ImageService imageService; + + @Transactional(readOnly = true) + public TripRetrospectDetail getTripRetrospect(Long memberId, Long tripId, int page, int size) { + Member member = memberQueryService.getValidMember(memberId); + Trip trip = tripQueryService.getValidCompletedTrip(member.getId(), tripId); // 완료된 여행 + Slice studyLogSlice = + studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size); + + long completedMissionCount = + missionQueryService.countCompletedMissionsByTripId(trip.getId()); + long totalFocusHours = pomodoroQueryService.getTotalFocusHoursByTripId(trip.getId()); + long studyDays = + trip.getEndDate() != null + ? Math.max( + 0, + ChronoUnit.DAYS.between(trip.getStartDate(), trip.getEndDate()) + 1) + : 0L; + + TripRetrospectSummary summary = + TripRetrospectSummary.of(completedMissionCount, totalFocusHours, studyDays); + TripInfo tripInfo = TripInfo.from(trip, 0, 100); + StudyLogSliceInfo studyLogDetailSlice = buildStudyLogDetailsSlice(studyLogSlice); + + return TripRetrospectDetail.from(summary, tripInfo, studyLogDetailSlice); + } + + @Transactional(readOnly = true) + public TripReportsInfo getTripReportsByMember(Long memberId) { + Member member = memberQueryService.getValidMember(memberId); + List tripReports = + tripReportQueryService.getTripReportsByMemberId(member.getId()); + + return TripReportsInfo.of(tripReports.stream().map(TripReportInfo::from).toList()); + } + + @Transactional(readOnly = true) + public TripReportDetail getTripReport(Long memberId, Long tripReportId, int page, int size) { + Member member = memberQueryService.getValidMember(memberId); + TripReport tripReport = + tripReportQueryService.getValidTripReport(member.getId(), tripReportId); + Slice studyLogSlice = + studyLogQueryService.getStudyLogsSliceByTripReportId( + tripReport.getId(), page, size); + + TripReportInfo tripReportInfo = TripReportInfo.from(tripReport); + StudyLogSliceInfo studyLogDetailSlice = buildStudyLogDetailsSlice(studyLogSlice); + + return TripReportDetail.from(tripReportInfo, studyLogDetailSlice); + } + + @Transactional + public TripReportInfo createTripReport(Long memberId, CreateTripReportRequest request) { + Member member = memberQueryService.getValidMember(memberId); + TripReport tripReport = tripReportCommandService.createTripReport(member, request); + List studyLogs = studyLogQueryService.getValidStudyLogs(request.studyLogIds()); + tripReportStudyLogCommandService.createTripReportStudyLogs(tripReport, studyLogs); + + return TripReportInfo.from(tripReport); + } + + @Transactional(readOnly = true) + public PresignedTripReportImageInfo issuePresignedUrl( + Long tripReportId, PresignTripReportImageRequest request) { + TripReport tripReport = tripReportQueryService.getTripReport(tripReportId); + + PresignedImageInfo info = + imageService.presign( + TRIP_REPORT_IMAGE_KEY_PREFIX, + tripReport.getId().toString(), + request.originFilename()); + + return PresignedTripReportImageInfo.of( + tripReport.getId(), info.tmpKey(), info.presignedUrl()); + } + + @Transactional + public void confirmImage(Long tripReportId, ConfirmTripReportImageRequest request) { + TripReport tripReport = tripReportQueryService.getTripReport(tripReportId); + String imageUrl = imageService.confirm(request.tmpKey()); + + tripReportCommandService.updateImageUrl(tripReport, imageUrl); + } + + private StudyLogSliceInfo buildStudyLogDetailsSlice(Slice studyLogSlice) { + List studyLogIds = studyLogSlice.getContent().stream().map(StudyLog::getId).toList(); + + // 학습 로그별 학습 로그 데일리 미션 목록 그룹화 + Map> groupedStudyLogDailyMissions = + studyLogDailyMissionQueryService.getGroupedStudyLogDailyMissionsByStudyLogIds( + studyLogIds); + + List studyLogDetails = + studyLogSlice.getContent().stream() + .map( + studyLog -> + StudyLogDetail.from( + studyLog, + groupedStudyLogDailyMissions.get(studyLog.getId()))) + .toList(); + + return StudyLogSliceInfo.of(studyLogDetails, studyLogSlice.hasNext()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripQueryService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripQueryService.java index 9eec2ce..b96f0e1 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripQueryService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripQueryService.java @@ -1,7 +1,7 @@ package com.ject.studytrip.trip.application.service; import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.trip.application.dto.TripCountInfo; +import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; @@ -41,7 +41,7 @@ public Slice getTripsSliceByMemberId(Long memberId, int page, int size) { return tripQueryRepository.findSliceByMemberId(memberId, PageRequest.of(page, size)); } - public TripCountInfo getActiveTripCountsByMemberId(Long memberId) { + public TripCount getActiveTripCountsByMemberId(Long memberId) { long courseCount = tripQueryRepository.countActiveTripsByMemberIdAndCategory( memberId, TripCategory.COURSE); @@ -49,6 +49,19 @@ public TripCountInfo getActiveTripCountsByMemberId(Long memberId) { tripQueryRepository.countActiveTripsByMemberIdAndCategory( memberId, TripCategory.EXPLORE); - return TripCountInfo.of(courseCount, exploreCount); + return TripCount.of(courseCount, exploreCount); + } + + public Trip getValidCompletedTrip(Long memberId, Long tripId) { + Trip trip = + tripRepository + .findById(tripId) + .orElseThrow(() -> new CustomException(TripErrorCode.TRIP_NOT_FOUND)); + + TripPolicy.validateOwner(memberId, trip); + TripPolicy.validateNotDeleted(trip); + TripPolicy.validateNotCompleted(trip); + + return trip; } } diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java new file mode 100644 index 0000000..48f4869 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportCommandService.java @@ -0,0 +1,35 @@ +package com.ject.studytrip.trip.application.service; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.trip.domain.factory.TripReportFactory; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TripReportCommandService { + private final TripReportRepository tripReportRepository; + + public TripReport createTripReport(Member member, CreateTripReportRequest request) { + TripReport tripReport = + TripReportFactory.create( + member, + request.title(), + request.content(), + request.startDate(), + request.endDate(), + request.completedMissionCount(), + request.totalFocusHours(), + request.studyDays(), + request.imageTitle()); + + return tripReportRepository.save(tripReport); + } + + public void updateImageUrl(TripReport tripReport, String imageUrl) { + tripReport.updateImageUrl(imageUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java new file mode 100644 index 0000000..9e9696f --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportQueryService.java @@ -0,0 +1,40 @@ +package com.ject.studytrip.trip.application.service; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.TripReportErrorCode; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.policy.TripReportPolicy; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TripReportQueryService { + private final TripReportRepository tripReportRepository; + + public TripReport getTripReport(Long tripReportId) { + return tripReportRepository + .findById(tripReportId) + .orElseThrow(() -> new CustomException(TripReportErrorCode.TRIP_REPORT_NOT_FOUND)); + } + + public TripReport getValidTripReport(Long memberId, Long tripReportId) { + TripReport tripReport = + tripReportRepository + .findById(tripReportId) + .orElseThrow( + () -> + new CustomException( + TripReportErrorCode.TRIP_REPORT_NOT_FOUND)); + + TripReportPolicy.validateOwner(memberId, tripReport); + + return tripReport; + } + + public List getTripReportsByMemberId(Long memberId) { + return tripReportRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java new file mode 100644 index 0000000..d12ec8f --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandService.java @@ -0,0 +1,25 @@ +package com.ject.studytrip.trip.application.service; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.trip.domain.factory.TripReportStudyLogFactory; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import com.ject.studytrip.trip.domain.repository.TripReportStudyLogRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TripReportStudyLogCommandService { + private final TripReportStudyLogRepository tripReportStudyLogRepository; + + public void createTripReportStudyLogs(TripReport tripReport, List studyLogs) { + List tripReportStudyLogs = + studyLogs.stream() + .map(studyLog -> TripReportStudyLogFactory.create(tripReport, studyLog)) + .toList(); + + tripReportStudyLogRepository.saveAll(tripReportStudyLogs); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java index 674bc44..a513aab 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java +++ b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java @@ -14,6 +14,7 @@ public enum TripErrorCode implements ErrorCode { COURSE_TRIP_END_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "코스형 여행은 종료일이 필수입니다."), TRIP_ALREADY_COMPLETED(HttpStatus.BAD_REQUEST, "이미 완료된 여행입니다."), TRIP_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 여행입니다."), + TRIP_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "여행이 아직 완료되지 않았습니다."), // 403 NOT_TRIP_OWNER(HttpStatus.FORBIDDEN, "요청한 여행 정보를 수정/삭제할 권한이 부족합니다."), diff --git a/src/main/java/com/ject/studytrip/trip/domain/error/TripReportErrorCode.java b/src/main/java/com/ject/studytrip/trip/domain/error/TripReportErrorCode.java new file mode 100644 index 0000000..4ecfc53 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/error/TripReportErrorCode.java @@ -0,0 +1,32 @@ +package com.ject.studytrip.trip.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum TripReportErrorCode implements ErrorCode { + // 403 + NOT_TRIP_REPORT_OWNER(HttpStatus.FORBIDDEN, "요청한 여행 리포트 정보를 조회할 권한이 없습니다."), + + // 404 + TRIP_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 여행 리포트가 존재하지 않습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportFactory.java b/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportFactory.java new file mode 100644 index 0000000..ebe4940 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportFactory.java @@ -0,0 +1,31 @@ +package com.ject.studytrip.trip.domain.factory; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.trip.domain.model.TripReport; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TripReportFactory { + public static TripReport create( + Member member, + String title, + String content, + String startDate, + String endDate, + long completedMissionCount, + long totalFocusHours, + long studyDays, + String imageTitle) { + return TripReport.of( + member, + title, + content, + startDate, + endDate, + completedMissionCount, + totalFocusHours, + studyDays, + imageTitle); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportStudyLogFactory.java b/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportStudyLogFactory.java new file mode 100644 index 0000000..a6fd2e4 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/factory/TripReportStudyLogFactory.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.trip.domain.factory; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TripReportStudyLogFactory { + public static TripReportStudyLog create(TripReport tripReport, StudyLog studyLog) { + return TripReportStudyLog.of(tripReport, studyLog); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/TripReport.java b/src/main/java/com/ject/studytrip/trip/domain/model/TripReport.java new file mode 100644 index 0000000..97036c5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/model/TripReport.java @@ -0,0 +1,75 @@ +package com.ject.studytrip.trip.domain.model; + +import static org.flywaydb.core.internal.util.StringUtils.hasText; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.member.domain.model.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class TripReport extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column private String title; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private String startDate; + + private String endDate; + + @Column(nullable = false) + private long completedMissionCount; + + @Column(nullable = false) + private long totalFocusHours; + + @Column(nullable = false) + private long studyDays; + + private String imageTitle; + + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + public static TripReport of( + Member member, + String startDate, + String endDate, + String title, + String content, + long completedMissionCount, + long totalFocusHours, + long studyDays, + String imageTitle) { + return TripReport.builder() + .title(title) + .content(content) + .startDate(startDate) + .endDate(endDate) + .completedMissionCount(completedMissionCount) + .totalFocusHours(totalFocusHours) + .studyDays(studyDays) + .imageTitle(imageTitle) + .imageUrl(null) + .member(member) + .build(); + } + + public void updateImageUrl(String imageUrl) { + if (hasText(imageUrl)) this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/TripReportStudyLog.java b/src/main/java/com/ject/studytrip/trip/domain/model/TripReportStudyLog.java new file mode 100644 index 0000000..d615385 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/model/TripReportStudyLog.java @@ -0,0 +1,32 @@ +package com.ject.studytrip.trip.domain.model; + +import static jakarta.persistence.FetchType.LAZY; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class TripReportStudyLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "trip_report_id") + private TripReport tripReport; + + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "study_log_id") + private StudyLog studyLog; + + public static TripReportStudyLog of(TripReport tripReport, StudyLog studyLog) { + return TripReportStudyLog.builder().tripReport(tripReport).studyLog(studyLog).build(); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java index 22a22cc..dc86173 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java +++ b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java @@ -43,4 +43,10 @@ public static void validateCompleted(Trip trip) { throw new CustomException(TripErrorCode.TRIP_ALREADY_COMPLETED); } } + + public static void validateNotCompleted(Trip trip) { + if (!trip.isCompleted()) { + throw new CustomException(TripErrorCode.TRIP_NOT_COMPLETED); + } + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/policy/TripReportPolicy.java b/src/main/java/com/ject/studytrip/trip/domain/policy/TripReportPolicy.java new file mode 100644 index 0000000..d9fdcb2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/policy/TripReportPolicy.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.trip.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.TripReportErrorCode; +import com.ject.studytrip.trip.domain.model.TripReport; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TripReportPolicy { + public static void validateOwner(Long memberId, TripReport tripReport) { + if (!tripReport.getMember().getId().equals(memberId)) { + throw new CustomException(TripReportErrorCode.NOT_TRIP_REPORT_OWNER); + } + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportRepository.java new file mode 100644 index 0000000..add4b81 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportRepository.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.trip.domain.repository; + +import com.ject.studytrip.trip.domain.model.TripReport; +import java.util.List; +import java.util.Optional; + +public interface TripReportRepository { + Optional findById(Long tripReportId); + + List findAllByMemberIdOrderByCreatedAtDesc(Long memberId); + + TripReport save(TripReport tripReport); +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogRepository.java new file mode 100644 index 0000000..2ad2660 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripReportStudyLogRepository.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.trip.domain.repository; + +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import java.util.List; + +public interface TripReportStudyLogRepository { + void saveAll(List tripReportStudyLogs); +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportJpaRepository.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportJpaRepository.java new file mode 100644 index 0000000..e1213f0 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportJpaRepository.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.TripReport; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TripReportJpaRepository extends JpaRepository { + List findAllByMember_IdOrderByCreatedAtDesc(Long memberId); +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportRepositoryAdapter.java new file mode 100644 index 0000000..80aaac4 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportRepositoryAdapter.java @@ -0,0 +1,29 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TripReportRepositoryAdapter implements TripReportRepository { + private final TripReportJpaRepository tripReportJpaRepository; + + @Override + public Optional findById(Long tripReportId) { + return tripReportJpaRepository.findById(tripReportId); + } + + @Override + public List findAllByMemberIdOrderByCreatedAtDesc(Long memberId) { + return tripReportJpaRepository.findAllByMember_IdOrderByCreatedAtDesc(memberId); + } + + @Override + public TripReport save(TripReport tripReport) { + return tripReportJpaRepository.save(tripReport); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogJpaRepository.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogJpaRepository.java new file mode 100644 index 0000000..7f9acd2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogJpaRepository.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TripReportStudyLogJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogRepositoryAdapter.java new file mode 100644 index 0000000..db52358 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripReportStudyLogRepositoryAdapter.java @@ -0,0 +1,18 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import com.ject.studytrip.trip.domain.repository.TripReportStudyLogRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TripReportStudyLogRepositoryAdapter implements TripReportStudyLogRepository { + private final TripReportStudyLogJpaRepository tripReportStudyLogJpaRepository; + + @Override + public void saveAll(List tripReportStudyLogs) { + tripReportStudyLogJpaRepository.saveAll(tripReportStudyLogs); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/controller/TripReportController.java b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripReportController.java new file mode 100644 index 0000000..69f97ca --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripReportController.java @@ -0,0 +1,170 @@ +package com.ject.studytrip.trip.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.trip.application.dto.*; +import com.ject.studytrip.trip.application.facade.TripReportFacade; +import com.ject.studytrip.trip.presentation.dto.request.ConfirmTripReportImageRequest; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +import com.ject.studytrip.trip.presentation.dto.request.PresignTripReportImageRequest; +import com.ject.studytrip.trip.presentation.dto.response.*; +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.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 = "TripReport", description = "여행 리포트 API") +@RestController +@RequestMapping +@RequiredArgsConstructor +@Validated +public class TripReportController { + private final TripReportFacade tripReportFacade; + + @Operation(summary = "여행 회고", description = "사용자가 여행을 완료한 후, 사용자가 진행했던 여행을 회고합니다.") + @GetMapping("/api/trips/{tripId}/retrospect") + public ResponseEntity loadTripRetrospect( + @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) { + TripRetrospectDetail result = + tripReportFacade.getTripRetrospect(Long.valueOf(memberId), tripId, page, size); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadTripRetrospectDetailResponse.of( + result.summary(), + result.tripInfo(), + result.studyLogDetailSlice()))); + } + + @Operation(summary = "여행 리포트 목록 조회", description = "사용자가 작성한 여행 리포트 목록을 조회합니다.") + @GetMapping("/api/trip-reports") + public ResponseEntity loadTripReports( + @AuthenticationPrincipal String memberId) { + TripReportsInfo result = tripReportFacade.getTripReportsByMember(Long.valueOf(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadTripReportsResponse.of(result.tripReportInfos()))); + } + + @Operation(summary = "여행 리포트 상세 조회", description = "사용자가 작성한 여행 리포트를 상세 조회합니다.") + @GetMapping("/api/trip-reports/{tripReportId}") + public ResponseEntity loadTripReport( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 리포트 ID는 필수 요청 파라미터입니다.") Long tripReportId, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size) { + TripReportDetail result = + tripReportFacade.getTripReport(Long.valueOf(memberId), tripReportId, page, size); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadTripReportDetailResponse.of( + result.tripReportInfo(), result.studyLogSliceInfo()))); + } + + @Operation(summary = "여행 리포트 생성", description = "사용자가 여행 회고에서 얻은 정보와 회고록을 기반으로 여행 리포트를 생성합니다.") + @PostMapping("/api/trip-reports") + public ResponseEntity createTripReport( + @AuthenticationPrincipal String memberId, + @RequestBody @Valid CreateTripReportRequest request) { + TripReportInfo result = tripReportFacade.createTripReport(Long.valueOf(memberId), request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + StandardResponse.success( + HttpStatus.CREATED.value(), CreateTripReportResponse.of(result))); + } + + @Operation( + summary = "여행 리포트 이미지 업로드용 Presigned URL 발급", + description = + """ + 여행 리포트 이미지를 S3에 업로드하기 위한 Presigned URL을 발급합니다. + + [흐름] + 1) 먼저 여행 리포트 생성 API를 호출해 TripReportId를 응답받습니다. + 2) 사용자가 이미지를 첨부했을 경우, 생성 시 받은 TripReportId를 PathVariable로 전달하여 + 업로드용 파일명 정보를 함께 Presigned URL 발급 API를 요청합니다. + 서버는 업로드에 사용할 Presigned PUT URL과 임시키(tmpKey)를 반환합니다. + 3) 반환받은 Presigned URL로 PUT 요청을 통해 이미지를 S3에 업로드합니다. + 4) 업로드가 정상적으로 완료되면 바로 학습 로그 이미지 Confirm API를 호출합니다. + 이때 Presigned URL 발급 API에서 반환받은 임시키(tmpKey)를 함께 요청합니다. + 서버는 업로드된 이미지를 검증(크키/MIME)하고 확정합니다. + + [주의] + - 여행 리포트 이미지 Presigned URL 발급 요청 API는 TripReportId가 필요하기 때문에 필수로 본 API를 호출하기 전 여행 리포트를 먼저 생성해야 합니다. + - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다. + - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. + """) + @PostMapping("/api/trip-reports/{tripReportId}/images/presigned") + public ResponseEntity presigned( + @PathVariable @NotNull(message = "여행 리포트 ID는 필수 요청 파라미터입니다.") Long tripReportId, + @RequestBody @Valid PresignTripReportImageRequest request) { + PresignedTripReportImageInfo info = + tripReportFacade.issuePresignedUrl(tripReportId, request); + return ResponseEntity.ok() + .body( + StandardResponse.success( + HttpStatus.OK.value(), + PresignedTripReportImageResponse.of( + info.tripReportId(), info.tmpKey(), info.presignedUrl()))); + } + + @Operation( + summary = "업로드된 여행 리포트 이미지 검증/확정", + description = + """ + Presigned URL을 통해 S3에 업로드된 여행 리포트 이미지를 서버에서 검증하고 확정(Confirm)합니다. + + [흐름] + 1) 클라이언트는 발급받은 URL로 이미지를 업로드합니다. + 2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다. + 임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다. + (예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/report-logs/1/abc.jpg -> tmp/report-logs/1/abc.jpg) + + 3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 여행 리포트 이미지 정보를 갱신합니다. + 만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만, + 유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야합니다. + + S3 Storage 기술 자체 예외 예시 + { + "status": 502 (BAD_GATEWAY), + "message": "Storage 서버 에러가 발생했습니다." + } + + 이미지 도메인 정책 위반 예외 예시 + { + "status": 400 (BAD_REQUEST), + "message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등 + } + + [주의] + - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. + - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. + - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. + """) + @PostMapping("/api/trip-reports/{tripReportId}/images/confirm") + public ResponseEntity confirm( + @PathVariable @NotNull(message = "여행 리포트 ID는 필수 요청 파라미터입니다.") Long tripReportId, + @RequestBody @Valid ConfirmTripReportImageRequest request) { + tripReportFacade.confirmImage(tripReportId, request); + return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/ConfirmTripReportImageRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/ConfirmTripReportImageRequest.java new file mode 100644 index 0000000..9b56779 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/ConfirmTripReportImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record ConfirmTripReportImageRequest( + @Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") + String tmpKey) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripReportRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripReportRequest.java new file mode 100644 index 0000000..b8fe3a5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripReportRequest.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateTripReportRequest( + @Schema(description = "여행 리포트 제목") @NotEmpty(message = "여행 리포트 제목은 필수 요청 값입니다.") + String title, + @Schema(description = "여행 리포트 내용") @NotEmpty(message = "여행 리포트 내용은 필수 요청 값입니다.") + String content, + @Schema(description = "여행 시작일") @NotEmpty(message = "여행 시작일은 필수 요청 값입니다.") String startDate, + @Schema(description = "여행 종료일") String endDate, + @Schema(description = "완료된 미션 수 (세션 성공)") @NotNull(message = "완료된 미션 수는 필수 요청 값입니다.") + long completedMissionCount, + @Schema(description = "총 학습 시간") @NotNull(message = "총 학습 시간은 필수 요청 값입니다.") + long totalFocusHours, + @Schema(description = "연속 학습일") @NotNull(message = "연속 학습일은 필수 요청 값입니다.") long studyDays, + @Schema(description = "이미지 제목") String imageTitle, + @Schema(description = "학스 로그 ID 목록") @NotEmpty(message = "학습 로그 ID 목록은 최소 1개 이상이어야 합니다.") + List<@NotNull(message = "학습 로그 ID는 필수 요청 값입니다.") Long> studyLogIds) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/PresignTripReportImageRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/PresignTripReportImageRequest.java new file mode 100644 index 0000000..0bf029a --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/PresignTripReportImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record PresignTripReportImageRequest( + @Schema(description = "원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") + String originFilename) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripReportResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripReportResponse.java new file mode 100644 index 0000000..2a210de --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripReportResponse.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.trip.application.dto.TripReportInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateTripReportResponse(@Schema(name = "여행 리포트 ID") Long tripReportId) { + public static CreateTripReportResponse of(TripReportInfo tripReportInfo) { + return new CreateTripReportResponse(tripReportInfo.tripReportId()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportDetailResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportDetailResponse.java new file mode 100644 index 0000000..0de73a7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportDetailResponse.java @@ -0,0 +1,36 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; +import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse; +import com.ject.studytrip.trip.application.dto.TripReportInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoadTripReportDetailResponse( + @Schema(description = "여행 리포트 ID") Long tripReportId, + @Schema(description = "여행 리포트 제목") String title, + @Schema(description = "여행 리포트 내용") String content, + @Schema(description = "여행 시작일 (여행 회고)") String startDate, + @Schema(description = "여행 종료일 (여행 회고)") String endDate, + @Schema(description = "총 학습 시간") long totalFocusHours, + @Schema(description = "완료된 미션 수 (세션 성공)") long completedMissionCount, + @Schema(description = "연속 학습일") long studyDays, + @Schema(description = "여행 리포트 이미지 제목") String imageTitle, + @Schema(description = "여행 리포트 이미지 URL") String imageUrl, + @Schema(description = "학습 로그 히스토리") LoadStudyLogsSliceResponse history) { + public static LoadTripReportDetailResponse of( + TripReportInfo tripReportInfo, StudyLogSliceInfo studyLogSliceInfo) { + return new LoadTripReportDetailResponse( + tripReportInfo.tripReportId(), + tripReportInfo.title(), + tripReportInfo.content(), + tripReportInfo.startDate(), + tripReportInfo.endDate(), + tripReportInfo.totalFocusHours(), + tripReportInfo.completedMissionCount(), + tripReportInfo.studyDays(), + tripReportInfo.imageTitle(), + tripReportInfo.imageUrl(), + LoadStudyLogsSliceResponse.of( + studyLogSliceInfo.studyLogDetails(), studyLogSliceInfo.hasNext())); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportsResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportsResponse.java new file mode 100644 index 0000000..72969e9 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripReportsResponse.java @@ -0,0 +1,45 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.trip.application.dto.TripReportInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record LoadTripReportsResponse( + TripReportSummary summary, List tripReports) { + public static LoadTripReportsResponse of(List tripReportInfos) { + return new LoadTripReportsResponse( + TripReportSummary.of(tripReportInfos), + tripReportInfos.stream().map(LoadTripReportInfoResponse::of).toList()); + } + + private record TripReportSummary( + @Schema(description = "여행 완료 수") long completedTripCount, + @Schema(description = "누적 학습 시간") long totalFocusHours, + @Schema(description = "가장 긴 학습 시간") long longestFocusHours) { + private static TripReportSummary of(List tripReportInfos) { + return new TripReportSummary( + tripReportInfos.size(), + tripReportInfos.stream().mapToLong(TripReportInfo::totalFocusHours).sum(), + tripReportInfos.stream() + .mapToLong(TripReportInfo::totalFocusHours) + .max() + .orElse(0)); + } + } + + private record LoadTripReportInfoResponse( + @Schema(description = "여행 리포트 ID") Long tripReportId, + @Schema(description = "여행 시작일 (여행 회고)") String startDate, + @Schema(description = "여행 종료일 (여행 회고)") String endDate, + @Schema(description = "총 학습 시간") long totalFocusHours, + @Schema(description = "여행 리포트 이미지 URL") String imageUrl) { + private static LoadTripReportInfoResponse of(TripReportInfo tripReportInfo) { + return new LoadTripReportInfoResponse( + tripReportInfo.tripReportId(), + tripReportInfo.startDate(), + tripReportInfo.endDate(), + tripReportInfo.totalFocusHours(), + tripReportInfo.imageUrl()); + } + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripRetrospectDetailResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripRetrospectDetailResponse.java new file mode 100644 index 0000000..124ce6e --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripRetrospectDetailResponse.java @@ -0,0 +1,31 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; +import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse; +import com.ject.studytrip.trip.application.dto.TripInfo; +import com.ject.studytrip.trip.application.dto.TripRetrospectSummary; +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoadTripRetrospectDetailResponse( + @Schema(description = "여행 이름") String name, + @Schema(description = "여행 시작일") String startDate, + @Schema(description = "여행 종료일") String endDate, + @Schema(description = "총 학습 시간") long totalFocusHours, + @Schema(description = "완료된 미션 수 (세션 성공)") long completedMissionCount, + @Schema(description = "연속 학습일") long studyDays, + @Schema(description = "학습 로그 히스토리") LoadStudyLogsSliceResponse history) { + public static LoadTripRetrospectDetailResponse of( + TripRetrospectSummary tripRetrospectSummary, + TripInfo tripInfo, + StudyLogSliceInfo studyLogDetailSlice) { + return new LoadTripRetrospectDetailResponse( + tripInfo.tripName(), + tripInfo.startDate(), + tripInfo.endDate(), + tripRetrospectSummary.totalFocusHours(), + tripRetrospectSummary.completedMissionCount(), + tripRetrospectSummary.studyDays(), + LoadStudyLogsSliceResponse.of( + studyLogDetailSlice.studyLogDetails(), studyLogDetailSlice.hasNext())); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/PresignedTripReportImageResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/PresignedTripReportImageResponse.java new file mode 100644 index 0000000..ef20172 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/PresignedTripReportImageResponse.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedTripReportImageResponse( + @Schema(description = "여행 리포트 ID") Long tripReportId, + @Schema(description = "여행 리포트 이미지 임시키") String tmpKey, + @Schema(description = "여행 리포트 이미지 업로드용 Presigned URL") String presignedUrl) { + public static PresignedTripReportImageResponse of( + Long tripReportId, String tmpKey, String presignedUrl) { + return new PresignedTripReportImageResponse(tripReportId, tmpKey, presignedUrl); + } +} diff --git a/src/main/resources/db/migration/V5__create_trip_report_table.sql b/src/main/resources/db/migration/V5__create_trip_report_table.sql new file mode 100644 index 0000000..e020b6d --- /dev/null +++ b/src/main/resources/db/migration/V5__create_trip_report_table.sql @@ -0,0 +1,27 @@ +CREATE TABLE trip_report ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + + -- 도메인 필드 + title VARCHAR(255) NOT NULL, + content VARCHAR(255) NOT NULL, + start_date VARCHAR(255) NOT NULL, + end_date VARCHAR(255) NULL, + completed_mission_count BIGINT NOT NULL, + total_focus_hours BIGINT NOT NULL, + study_days BIGINT NOT NULL, + image_title VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NULL, + + -- 외래키 필드 + member_id BIGINT NOT NULL, + + -- Auditing + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + -- 외래키 제약조건 + FOREIGN KEY (member_id) REFERENCES member (id) + ON UPDATE RESTRICT + ON DELETE RESTRICT +); diff --git a/src/main/resources/db/migration/V6__create_trip_report_study_log_table.sql b/src/main/resources/db/migration/V6__create_trip_report_study_log_table.sql new file mode 100644 index 0000000..e0bcb7e --- /dev/null +++ b/src/main/resources/db/migration/V6__create_trip_report_study_log_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE trip_report_study_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + + -- 외래키 필드 + trip_report_id BIGINT NOT NULL, + study_log_id BIGINT NOT NULL, + + -- Auditing + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + -- 외래키 제약조건 + FOREIGN KEY (trip_report_id) REFERENCES trip_report (id) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + + FOREIGN KEY (study_log_id) REFERENCES study_log (id) + ON UPDATE RESTRICT + ON DELETE RESTRICT +); diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.java index d8b036c..cd50464 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.java @@ -34,6 +34,7 @@ class MissionQueryServiceTest extends BaseUnitTest { @Mock private MissionRepository missionRepository; @Mock private MissionQueryRepository missionQueryRepository; + private Trip exploreTrip; private Stamp courseStamp; private Stamp exploreStamp; private Mission courseMission; @@ -44,7 +45,7 @@ class MissionQueryServiceTest extends BaseUnitTest { void setUp() { Member member = MemberFixture.createMemberFromKakao(); Trip courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - Trip exploreTrip = TripFixture.createTripWithId(2L, member, TripCategory.EXPLORE); + exploreTrip = TripFixture.createTripWithId(2L, member, TripCategory.EXPLORE); courseStamp = StampFixture.createStampWithId(1L, courseTrip, 1); exploreStamp = StampFixture.createStampWithId(2L, exploreTrip, 0); courseMission = MissionFixture.createMissionWithId(1L, courseStamp); @@ -197,4 +198,40 @@ void shouldReturnValidMissions() { assertThat(result).containsExactly(courseMission); } } + + @Nested + @DisplayName("countCompletedMissionsByTripId 메서드는") + class CountCompletedMissionsByTripId { + + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 0을 반환한다.") + void shouldReturnZeroWhenTripIdIsInvalid() { + // given + Long invalidTripId = -1L; + given(missionQueryRepository.countCompletedMissionsByTripId(invalidTripId)) + .willReturn(0L); + + // when + long result = missionQueryService.countCompletedMissionsByTripId(invalidTripId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("유효한 여행 ID가 들어오면 완료된 미션들의 개수를 반환한다.") + void shouldReturnCompletedMissionCountWhenTripIdIsValid() { + // given + Long tripId = exploreTrip.getId(); + exploreMission1.updateCompleted(); + exploreMission2.updateCompleted(); + given(missionQueryRepository.countCompletedMissionsByTripId(tripId)).willReturn(2L); + + // when + long result = missionQueryService.countCompletedMissionsByTripId(tripId); + + // then + assertThat(result).isEqualTo(2L); + } + } } diff --git a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.java b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.java index dedf473..d4a0a37 100644 --- a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.java @@ -10,6 +10,7 @@ import com.ject.studytrip.member.fixture.MemberFixture; import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; import com.ject.studytrip.pomodoro.domain.model.Pomodoro; +import com.ject.studytrip.pomodoro.domain.repository.PomodoroQueryRepository; import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; import com.ject.studytrip.trip.domain.model.DailyGoal; @@ -29,14 +30,16 @@ class PomodoroQueryServiceTest extends BaseUnitTest { @InjectMocks private PomodoroQueryService pomodoroQueryService; @Mock private PomodoroRepository pomodoroRepository; + @Mock private PomodoroQueryRepository pomodoroQueryRepository; + private Trip trip; private DailyGoal dailyGoal; private Pomodoro pomodoro; @BeforeEach void setUp() { Member member = MemberFixture.createMemberFromKakaoWithId(1L); - Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); pomodoro = PomodoroFixture.createPomodoroWithId(1L, dailyGoal); } @@ -89,4 +92,39 @@ void shouldThrowExceptionWhenDeletedPomodoro() { .hasMessage(PomodoroErrorCode.POMODORO_ALREADY_DELETED.getMessage()); } } + + @Nested + @DisplayName("getTotalFocusHoursByTripId 메서드는") + class GetTotalFocusHoursByTripId { + + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 0을 반환한다.") + void shouldReturnZeroWhenTripIdIsInvalid() { + // given + Long tripId = trip.getId(); + given(pomodoroQueryRepository.sumFocusHoursByTripId(tripId)).willReturn(0L); + + // when + long result = pomodoroQueryService.getTotalFocusHoursByTripId(tripId); + + // then + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("유효한 여행 ID가 들어오면 총 집중 시간(시간 단위)을 반환한다.") + void shouldReturnTotalFocusHoursWhenTripIdIsValid() { + // given + Long tripId = trip.getId(); + long totalFocusHours = 120L; + given(pomodoroQueryRepository.sumFocusHoursByTripId(tripId)) + .willReturn(totalFocusHours); + + // when + long result = pomodoroQueryService.getTotalFocusHoursByTripId(tripId); + + // then + assertThat(result).isEqualTo(totalFocusHours); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java index c20dc24..001bc16 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java @@ -1,7 +1,7 @@ package com.ject.studytrip.studylog.application.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.BDDMockito.given; import com.ject.studytrip.BaseUnitTest; @@ -16,8 +16,10 @@ import com.ject.studytrip.trip.domain.model.DailyGoal; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.model.TripReport; import com.ject.studytrip.trip.fixture.DailyGoalFixture; import com.ject.studytrip.trip.fixture.TripFixture; +import com.ject.studytrip.trip.fixture.TripReportFixture; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -39,11 +41,17 @@ class StudyLogQueryServiceTest extends BaseUnitTest { private Member member; private Trip courseTrip; + private DailyGoal dailyGoal; + private StudyLog studyLog1; + private StudyLog studyLog2; @BeforeEach void setUp() { member = MemberFixture.createMemberFromKakaoWithId(1L); courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + studyLog1 = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + studyLog2 = StudyLogFixture.createStudyLogWithId(2L, member, dailyGoal); } @Nested @@ -88,9 +96,6 @@ class getStudyLogsSliceByTripId { void shouldReturnStudyLogsByTripIdWithSlice() { // given Long tripId = courseTrip.getId(); - 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; @@ -134,7 +139,6 @@ void shouldThrowExceptionWhenStudyLogNotFound() { @DisplayName("삭제된 학습 로그를 조회하면 예외가 발생한다") void shouldThrowExceptionWhenStudyLogIsDeleted() { // given - DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); studyLog.updateDeletedAt(); @@ -150,7 +154,6 @@ void shouldThrowExceptionWhenStudyLogIsDeleted() { @DisplayName("유효한 학습 로그 ID로 조회하면 학습 로그를 반환한다") void shouldReturnStudyLogWhenIdIsValid() { // given - DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); given(studyLogRepository.findById(1L)).willReturn(Optional.of(studyLog)); @@ -163,4 +166,37 @@ void shouldReturnStudyLogWhenIdIsValid() { assertThat(result.getDeletedAt()).isNull(); } } + + @Nested + @DisplayName("getStudyLogsSliceByTripId 메서드는") + class GetStudyLogsSliceByTripReportId { + + @Test + @DisplayName("특정 여행 리포트의 학습 로그 목록을 페이징 처리와 최신순으로 정렬하고 반환한다") + void shouldReturnStudyLogsByTripReportIdWithSlice() { + // given + List studyLogs = List.of(studyLog1, studyLog2); + TripReport tripReport = TripReportFixture.createTripReportWithId(1L, member); + + int page = 0; + int size = 5; + Pageable pageable = PageRequest.of(page, size); + + Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); + + given( + studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc( + tripReport.getId(), pageable)) + .willReturn(mockSlice); + + // when + Slice result = + studyLogQueryService.getStudyLogsSliceByTripId(tripReport.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/trip/application/service/TripQueryServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripQueryServiceTest.java index 03aec51..6c16ed4 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripQueryServiceTest.java @@ -8,7 +8,7 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.fixture.MemberFixture; -import com.ject.studytrip.trip.application.dto.TripCountInfo; +import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; @@ -171,7 +171,7 @@ void shouldReturnZeroWhenTripDoesNotExistForMember() { .willReturn(0L); // when - TripCountInfo result = tripQueryService.getActiveTripCountsByMemberId(memberId); + TripCount result = tripQueryService.getActiveTripCountsByMemberId(memberId); // then assertThat(result.course()).isZero(); @@ -193,11 +193,72 @@ void shouldReturnTripCountByCategory() { .willReturn(2L); // when - TripCountInfo result = tripQueryService.getActiveTripCountsByMemberId(memberId); + TripCount result = tripQueryService.getActiveTripCountsByMemberId(memberId); // then assertThat(result.course()).isEqualTo(3L); assertThat(result.explore()).isEqualTo(2L); } } + + @Nested + @DisplayName("getValidCompletedTrip 메서드는") + class GetValidCompletedTrip { + + @Test + @DisplayName("여행의 소유자가 아닐 경우 예외가 발생한다") + void shouldThrowExceptionWhenNotTripOwner() { + // given + Member newMember = MemberFixture.createMemberFromKakao(); + Long tripId = trip.getId(); + given(tripRepository.findById(tripId)).willReturn(Optional.of(trip)); + + // When & Then + assertThatThrownBy(() -> tripQueryService.getValidTrip(newMember.getId(), tripId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.NOT_TRIP_OWNER.getMessage()); + } + + @Test + @DisplayName("이미 삭제된 여행일 경우 예외가 발생한다") + void shouldThrowExceptionWhenTripIsDeleted() { + // given + trip.updateDeletedAt(); + Long deletedId = trip.getId(); + given(tripRepository.findById(deletedId)).willReturn(Optional.of(trip)); + + // when & then + assertThatThrownBy(() -> tripQueryService.getValidTrip(member.getId(), deletedId)) + .isInstanceOf(CustomException.class) + .hasMessage(TripErrorCode.TRIP_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("여행이 아직 완료되지 않은 경우 예외가 발생한다") + void shouldThrowExceptionWhenTripDoesNotCompletedYet() { + // given + Long tripId = trip.getId(); + given(tripRepository.findById(tripId)).willReturn(Optional.of(trip)); + + // when & then + assertThatThrownBy(() -> tripQueryService.getValidCompletedTrip(member.getId(), tripId)) + .isInstanceOf(CustomException.class) + .hasMessage(TripErrorCode.TRIP_NOT_COMPLETED.getMessage()); + } + + @Test + @DisplayName("여행이 이미 완료되었다면 여행을 반환한다.") + void shouldReturnTripWhenTripAlreadyCompleted() { + // given + trip.updateCompleted(); + given(tripRepository.findById(trip.getId())).willReturn(Optional.of(trip)); + + // when + Trip result = tripQueryService.getValidCompletedTrip(member.getId(), trip.getId()); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(trip.getId()); + } + } } diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java new file mode 100644 index 0000000..e9dcadd --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.java @@ -0,0 +1,75 @@ +package com.ject.studytrip.trip.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +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.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import com.ject.studytrip.trip.fixture.CreateTripReportRequestFixture; +import com.ject.studytrip.trip.fixture.TripReportFixture; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +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("TripReportCommandService 단위 테스트") +class TripReportCommandServiceTest extends BaseUnitTest { + @InjectMocks private TripReportCommandService tripReportCommandService; + @Mock private TripReportRepository tripReportRepository; + + private Member member; + private TripReport tripReport; + + @BeforeEach + void setup() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + tripReport = TripReportFixture.createTripReport(member); + } + + @Nested + @DisplayName("createTripReport 메서드는") + class CreateTripReport { + + @Test + @DisplayName("유효한 요청이 들어오면 여행 리포트를 생성하고 반환한다.") + void shouldReturnTripReportWhenRequestIsValid() { + // given + CreateTripReportRequest request = new CreateTripReportRequestFixture().build(); + given(tripReportRepository.save(any(TripReport.class))).willReturn(tripReport); + + // when + TripReport result = tripReportCommandService.createTripReport(member, request); + + // then + assertThat(result).isEqualTo(tripReport); + } + } + + @Nested + @DisplayName("updateImageUrl 메서드는") + class UpdateImageUrl { + private static final String NEW_IMAGE_URL = + "https://cdn.example.com/trip-reports/1/image.jpg"; + + @Test + @DisplayName("유효한 여행 리포트의 이미지 URL을 수정한다.") + void shouldUpdateImageUrlWhenTripReportIsValid() { + // given + String oldImageUrl = tripReport.getImageUrl(); + + // when + tripReportCommandService.updateImageUrl(tripReport, NEW_IMAGE_URL); + + // then + assertThat(tripReport.getImageUrl()).isEqualTo(NEW_IMAGE_URL); + assertThat(tripReport.getImageUrl()).isNotEqualTo(oldImageUrl); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java new file mode 100644 index 0000000..a2398d3 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.java @@ -0,0 +1,170 @@ +package com.ject.studytrip.trip.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.trip.domain.error.TripReportErrorCode; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import com.ject.studytrip.trip.fixture.TripReportFixture; +import java.util.List; +import java.util.Optional; +import org.assertj.core.api.AssertionsForClassTypes; +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("TripReportQueryService 단위 테스트") +class TripReportQueryServiceTest extends BaseUnitTest { + @InjectMocks private TripReportQueryService tripReportQueryService; + @Mock private TripReportRepository tripReportRepository; + + private Member member; + private TripReport tripReport1; + private TripReport tripReport2; + + @BeforeEach + void setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + tripReport1 = TripReportFixture.createTripReportWithId(1L, member); + tripReport2 = TripReportFixture.createTripReportWithId(2L, member); + } + + @Nested + @DisplayName("getTripReport 메서드는") + class GetTripReport { + + @Test + @DisplayName("존재하지 않는 여행 리포트로 조회하면 예외가 발생한다.") + void shouldThrowExceptionWhenTripReportDoNotExist() { + // given + Long invalidId = -1L; + given(tripReportRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + tripReportQueryService.getValidTripReport( + member.getId(), invalidId)) + .isInstanceOf(CustomException.class) + .hasMessage(TripReportErrorCode.TRIP_REPORT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("여행 리포트가 존재하면 여행 리포트를 반환한다.") + void shouldReturnValidTripReportWhenTripReportExist() { + // given + Long tripReportId = tripReport1.getId(); + given(tripReportRepository.findById(tripReportId)).willReturn(Optional.of(tripReport1)); + + // when + TripReport result = + tripReportQueryService.getValidTripReport(member.getId(), tripReportId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(tripReportId); + assertThat(result.getMember().getId()).isEqualTo(member.getId()); + } + } + + @Nested + @DisplayName("getValidTripReport 메서드는") + class GetValidTripReport { + + @Test + @DisplayName("존재하지 않는 여행 리포트로 조회하면 예외가 발생한다.") + void shouldThrowExceptionWhenTripReportDoNotExist() { + // given + Long invalidId = -1L; + given(tripReportRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + tripReportQueryService.getValidTripReport( + member.getId(), invalidId)) + .isInstanceOf(CustomException.class) + .hasMessage(TripReportErrorCode.TRIP_REPORT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("여행 리포트의 소유자가 아니라면 예외가 발생한다.") + void shouldThrowExceptionWhenNotTripReportOwner() { + // given + Member newMember = MemberFixture.createMemberFromKakaoWithId(2L); + Long tripReportId = tripReport1.getId(); + given(tripReportRepository.findById(tripReportId)).willReturn(Optional.of(tripReport1)); + + // When & Then + AssertionsForClassTypes.assertThatThrownBy( + () -> + tripReportQueryService.getValidTripReport( + newMember.getId(), tripReportId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripReportErrorCode.NOT_TRIP_REPORT_OWNER.getMessage()); + } + + @Test + @DisplayName("여행 리포트가 존재하면 여행 리포트를 반환한다.") + void shouldReturnValidTripReportWhenTripReportExist() { + // given + Long tripReportId = tripReport1.getId(); + given(tripReportRepository.findById(tripReportId)).willReturn(Optional.of(tripReport1)); + + // when + TripReport result = + tripReportQueryService.getValidTripReport(member.getId(), tripReportId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(tripReportId); + assertThat(result.getMember().getId()).isEqualTo(member.getId()); + } + } + + @Nested + @DisplayName("getTripReportsByMemberId 메서드는") + class GetTripReportsByMemberId { + + @Test + @DisplayName("여행 리포트가 존재하지 않으면 빈 리스트를 반환한다.") + void shouldReturnEmptyListWhenTripReportDoNotExist() { + // given + Long memberId = member.getId(); + given(tripReportRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId)) + .willReturn(List.of()); + + // when + List result = tripReportQueryService.getTripReportsByMemberId(memberId); + + // then + assertThat(result.size()).isEqualTo(0); + } + + @Test + @DisplayName("여행 리포트가 하나라도 존재하면 특정 멤버가 생성한 여행 리포트 리스트를 반환한다.") + void shouldReturnTripReportsWhenTripReportExists() { + // given + Long memberId = member.getId(); + given(tripReportRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId)) + .willReturn(List.of(tripReport1, tripReport2)); + + // when + List result = tripReportQueryService.getTripReportsByMemberId(memberId); + + // then + assertThat(result.size()).isEqualTo(2); + assertThat(result.get(0).getId()).isEqualTo(tripReport1.getId()); + assertThat(result.get(1).getId()).isEqualTo(tripReport2.getId()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java new file mode 100644 index 0000000..6f32291 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java @@ -0,0 +1,62 @@ +package com.ject.studytrip.trip.application.service; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +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.fixture.StudyLogFixture; +import com.ject.studytrip.trip.domain.model.*; +import com.ject.studytrip.trip.domain.repository.TripReportStudyLogRepository; +import com.ject.studytrip.trip.fixture.DailyGoalFixture; +import com.ject.studytrip.trip.fixture.TripFixture; +import com.ject.studytrip.trip.fixture.TripReportFixture; +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; + +@DisplayName("TripReportStudyLogCommandService 단위 테스트") +class TripReportStudyLogCommandServiceTest extends BaseUnitTest { + @InjectMocks private TripReportStudyLogCommandService tripReportStudyLogCommandService; + @Mock private TripReportStudyLogRepository tripReportStudyLogRepository; + + private TripReport tripReport; + private List studyLogs; + + @BeforeEach + void setUp() { + Member member = MemberFixture.createMemberFromKakaoWithId(1L); + Trip trip = TripFixture.createTrip(member, TripCategory.COURSE); + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoal(trip); + StudyLog studyLog1 = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + StudyLog studyLog2 = StudyLogFixture.createStudyLogWithId(2L, member, dailyGoal); + tripReport = TripReportFixture.createTripReportWithId(1L, member); + studyLogs = List.of(studyLog1, studyLog2); + } + + @Nested + @DisplayName("createTripReportStudyLogs 메서드는") + class CreateTripReportStudyLogs { + + @Test + @DisplayName("여행 리포트와 학습 로그 목록으로 여행 리포트 학습 로그를 생성한다.") + void shouldCreateTripReportStudyLogs() { + // given + willDoNothing().given(tripReportStudyLogRepository).saveAll(anyList()); + + // when + tripReportStudyLogCommandService.createTripReportStudyLogs(tripReport, studyLogs); + + // then + verify(tripReportStudyLogRepository, times(1)).saveAll(anyList()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/ConfirmTripReportImageRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/ConfirmTripReportImageRequestFixture.java new file mode 100644 index 0000000..d6f2aa3 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/ConfirmTripReportImageRequestFixture.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.presentation.dto.request.ConfirmTripReportImageRequest; + +public class ConfirmTripReportImageRequestFixture { + private String tmpKey = "tmp/trip-reports/1/test.jpg"; + + public ConfirmTripReportImageRequest build() { + return new ConfirmTripReportImageRequest(tmpKey); + } + + public ConfirmTripReportImageRequestFixture withTmpKey(String tmpKey) { + this.tmpKey = tmpKey; + return this; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/CreateTripReportRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/CreateTripReportRequestFixture.java new file mode 100644 index 0000000..f3a13f7 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/CreateTripReportRequestFixture.java @@ -0,0 +1,35 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +import java.util.List; + +public class CreateTripReportRequestFixture { + private static final String TRIP_REPORT_TITLE = "TEST TITLE"; + private static final String TRIP_REPORT_CONTENT = "TEST CONTENT"; + private static final String TRIP_START_DATE = "2018.01.01"; + private static final String TRIP_END_DATE = "2018.01.31"; + private static final long TRIP_REPORT_COMPLETED_MISSION_COUNT = 10L; + private static final long TRIP_REPORT_TOTAL_FOCUS_HOURS = 100L; + private static final long TRIP_REPORT_STUDY_DAYS = 10L; + private static final String TRIP_REPORT_IMAGE_TITLE = "TEST IMAGE TITLE"; + + private List studyLogIds = List.of(1L, 2L); + + public CreateTripReportRequestFixture withStudyLogIds(List studyLogIds) { + this.studyLogIds = studyLogIds; + return this; + } + + public CreateTripReportRequest build() { + return new CreateTripReportRequest( + TRIP_REPORT_TITLE, + TRIP_REPORT_CONTENT, + TRIP_START_DATE, + TRIP_END_DATE, + TRIP_REPORT_COMPLETED_MISSION_COUNT, + TRIP_REPORT_TOTAL_FOCUS_HOURS, + TRIP_REPORT_STUDY_DAYS, + TRIP_REPORT_IMAGE_TITLE, + studyLogIds); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/PresignTripReportImageRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/PresignTripReportImageRequestFixture.java new file mode 100644 index 0000000..295fb18 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/PresignTripReportImageRequestFixture.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.presentation.dto.request.PresignTripReportImageRequest; + +public class PresignTripReportImageRequestFixture { + private String originFilename = "test.png"; + + public PresignTripReportImageRequest build() { + return new PresignTripReportImageRequest(originFilename); + } + + public PresignTripReportImageRequestFixture withOriginFilename(String originFilename) { + this.originFilename = originFilename; + return this; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/TripReportFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/TripReportFixture.java new file mode 100644 index 0000000..d10573f --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/TripReportFixture.java @@ -0,0 +1,47 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.trip.domain.factory.TripReportFactory; +import com.ject.studytrip.trip.domain.model.TripReport; +import org.springframework.test.util.ReflectionTestUtils; + +public class TripReportFixture { + private static final String TRIP_REPORT_TITLE = "TEST TITLE"; + private static final String TRIP_REPORT_CONTENT = "TEST CONTENT"; + private static final String TRIP_START_DATE = "2018.01.01"; + private static final String TRIP_END_DATE = "2018.01.31"; + private static final long TRIP_REPORT_COMPLETED_MISSION_COUNT = 10L; + private static final long TRIP_REPORT_TOTAL_FOCUS_HOURS = 100L; + private static final long TRIP_REPORT_STUDY_DAYS = 10L; + private static final String TRIP_REPORT_IMAGE_TITLE = "TEST IMAGE TITLE"; + + public static TripReport createTripReport(Member member) { + return TripReportFactory.create( + member, + TRIP_REPORT_TITLE, + TRIP_REPORT_CONTENT, + TRIP_START_DATE, + TRIP_END_DATE, + TRIP_REPORT_COMPLETED_MISSION_COUNT, + TRIP_REPORT_TOTAL_FOCUS_HOURS, + TRIP_REPORT_STUDY_DAYS, + TRIP_REPORT_IMAGE_TITLE); + } + + public static TripReport createTripReportWithId(Long id, Member member) { + TripReport tripReport = + TripReportFactory.create( + member, + TRIP_REPORT_TITLE, + TRIP_REPORT_CONTENT, + TRIP_START_DATE, + TRIP_END_DATE, + TRIP_REPORT_COMPLETED_MISSION_COUNT, + TRIP_REPORT_TOTAL_FOCUS_HOURS, + TRIP_REPORT_STUDY_DAYS, + TRIP_REPORT_IMAGE_TITLE); + ReflectionTestUtils.setField(tripReport, "id", id); + + return tripReport; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/TripReportStudyLogFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/TripReportStudyLogFixture.java new file mode 100644 index 0000000..d0e0b6a --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/TripReportStudyLogFixture.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.trip.domain.factory.TripReportStudyLogFactory; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.model.TripReportStudyLog; +import org.springframework.test.util.ReflectionTestUtils; + +public class TripReportStudyLogFixture { + + public static TripReportStudyLog createTripReportStudyLog( + TripReport tripReport, StudyLog studyLog) { + return TripReportStudyLogFactory.create(tripReport, studyLog); + } + + public static TripReportStudyLog createTripReportStudyLogWithId( + Long id, TripReport tripReport, StudyLog studyLog) { + TripReportStudyLog tripReportStudyLog = + TripReportStudyLogFactory.create(tripReport, studyLog); + ReflectionTestUtils.setField(tripReportStudyLog, "id", id); + + return tripReportStudyLog; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/helper/TripReportTestHelper.java b/src/test/java/com/ject/studytrip/trip/helper/TripReportTestHelper.java new file mode 100644 index 0000000..9f993b4 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/helper/TripReportTestHelper.java @@ -0,0 +1,19 @@ +package com.ject.studytrip.trip.helper; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.domain.repository.TripReportRepository; +import com.ject.studytrip.trip.fixture.TripReportFixture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TripReportTestHelper { + + @Autowired private TripReportRepository tripReportRepository; + + public TripReport saveTripReport(Member member) { + TripReport tripReport = TripReportFixture.createTripReport(member); + return tripReportRepository.save(tripReport); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java b/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java index 7d1e790..83a72d3 100644 --- a/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java +++ b/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java @@ -23,4 +23,10 @@ public Trip saveDeletedTrip(Member member, TripCategory category) { trip.updateDeletedAt(); return tripRepository.save(trip); } + + public Trip saveCompletedTrip(Member member, TripCategory category) { + Trip trip = TripFixture.createTrip(member, category); + trip.updateCompleted(); + return tripRepository.save(trip); + } } diff --git a/src/test/java/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.java new file mode 100644 index 0000000..a29f8bd --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.java @@ -0,0 +1,805 @@ +package com.ject.studytrip.trip.presentation.controller; + +import static com.ject.studytrip.auth.fixture.TokenFixture.TOKEN_PREFIX; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +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.image.domain.error.ImageErrorCode; +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.helper.MemberTestHelper; +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.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.helper.StampTestHelper; +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.helper.StudyLogDailyMissionTestHelper; +import com.ject.studytrip.studylog.helper.StudyLogTestHelper; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.error.TripReportErrorCode; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.model.TripReport; +import com.ject.studytrip.trip.fixture.ConfirmTripReportImageRequestFixture; +import com.ject.studytrip.trip.fixture.CreateTripReportRequestFixture; +import com.ject.studytrip.trip.fixture.PresignTripReportImageRequestFixture; +import com.ject.studytrip.trip.helper.DailyGoalTestHelper; +import com.ject.studytrip.trip.helper.TripReportTestHelper; +import com.ject.studytrip.trip.helper.TripTestHelper; +import com.ject.studytrip.trip.presentation.dto.request.ConfirmTripReportImageRequest; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripReportRequest; +import com.ject.studytrip.trip.presentation.dto.request.PresignTripReportImageRequest; +import java.util.List; +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.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("TripReportController 통합 테스트") +class TripReportControllerIntegrationTest 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 StudyLogTestHelper studyLogTestHelper; + @Autowired private DailyMissionTestHelper dailyMissionTestHelper; + @Autowired private StudyLogDailyMissionTestHelper studyLogDailyMissionTestHelper; + @Autowired private TripReportTestHelper tripReportTestHelper; + + @MockitoBean S3ImageStorageProvider s3ImageStorageProvider; + + private String accessToken; + private String newAccessToken; + + private Member member; + private Trip courseTrip; + + private TripReport tripReport; + private StudyLog studyLog1; + private StudyLog studyLog2; + + @BeforeEach + void setUp() { + member = memberTestHelper.saveMember(); + Member newMember = memberTestHelper.saveMember("test@kakao.com", "TEST NICKNAME"); + + accessToken = + tokenTestHelper.createAccessToken( + member.getId().toString(), member.getRole().name()); + newAccessToken = + tokenTestHelper.createAccessToken( + newMember.getId().toString(), newMember.getRole().name()); + + courseTrip = tripTestHelper.saveCompletedTrip(member, TripCategory.COURSE); + Stamp stamp = stampTestHelper.saveStamp(courseTrip, 1); + DailyGoal dailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); + Mission mission = missionTestHelper.saveMission(stamp); + DailyMission dailyMission = dailyMissionTestHelper.saveDailyMission(mission, dailyGoal); + tripReport = tripReportTestHelper.saveTripReport(member); + studyLog1 = studyLogTestHelper.saveStudyLog(member, dailyGoal); + studyLog2 = studyLogTestHelper.saveStudyLog(member, dailyGoal); + studyLogDailyMissionTestHelper.saveStudyLogDailyMissions(studyLog2, dailyMission); + } + + @Nested + @DisplayName("여행 회고 API") + class LoadTripRetrospect { + private static final String DEFAULT_PAGE = "0"; + private static final String DEFAULT_PAGE_SIZE = "5"; + + private ResultActions getResultActions( + String accessToken, Object tripId, String page, String size) throws Exception { + return mockMvc.perform( + get("/api/trips/{tripId}/retrospect", tripId) + .param("page", page) + .param("size", size) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() 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("Request Param 페이징 데이터 타입이 올바르지 않으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenWhenPagingParameterTypeMismatch() throws Exception { + // Given + String page = "test"; + String size = "test"; + + // when + ResultActions resultActions = getResultActions(accessToken, 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(accessToken, 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("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { + // given + String invalidTripId = "abc"; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, 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("삭제된 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripAlreadyDeleted() throws Exception { + // given + courseTrip.updateDeletedAt(); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip.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())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_ALREADY_DELETED.getMessage())); + } + + @Test + @DisplayName("아직 완료되지 않은 여행일 경우 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripDoesNotCompleted() throws Exception { + // given + Trip courseTrip2 = tripTestHelper.saveTrip(member, TripCategory.COURSE); + + // when + ResultActions resultActions = + getResultActions( + accessToken, courseTrip2.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(TripErrorCode.TRIP_NOT_COMPLETED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_NOT_COMPLETED.getMessage())); + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripOwner() throws Exception { + // when + ResultActions resultActions = + getResultActions( + newAccessToken, courseTrip.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())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.NOT_TRIP_OWNER.getMessage())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { + // given + Long invalidTripId = 10000L; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidTripId, 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())) + .andExpect( + jsonPath("$.data.message") + .value(TripErrorCode.TRIP_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 여행 ID가 들어오면 여행 회고 정보를 반환한다.") + void shouldReturnTripRetrospectWhenTripIdIsValid() throws Exception { + // when + ResultActions result = getResultActions(accessToken, courseTrip.getId(), "1", "10"); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.name").isString()) + .andExpect(jsonPath("$.data.totalFocusHours").isNumber()) + .andExpect(jsonPath("$.data.completedMissionCount").isNumber()) + .andExpect(jsonPath("$.data.studyDays").isNumber()) + .andExpect(jsonPath("$.data.history").isNotEmpty()); + } + } + + @Nested + @DisplayName("여행 리포트 목록 조회 API") + class LoadTripReports { + private ResultActions getResultActions(String accessToken, Object tripId) throws Exception { + return mockMvc.perform( + get("/api/trip-reports", tripId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = getResultActions("", courseTrip.getId()); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("유효한 멤버 ID가 들어오면 여행 리포트 목록을 반환한다.") + void shouldReturnLoadTripReportsWhenMemberIdIsValid() throws Exception { + // given + Long memberId = member.getId(); + + // when + ResultActions resultActions = getResultActions(accessToken, memberId); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.summary").exists()) + .andExpect(jsonPath("$.data.tripReports").isArray()); + } + } + + @Nested + @DisplayName("여행 리포트 상세 조회 API") + class LoadTripReport { + private static final String DEFAULT_PAGE = "0"; + private static final String DEFAULT_PAGE_SIZE = "5"; + + private ResultActions getResultActions( + String accessToken, Object tripReportId, String page, String size) + throws Exception { + return mockMvc.perform( + get("/api/trip-reports/{tripReportId}", tripReportId) + .param("page", page) + .param("size", size) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() 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("Request Param 페이징 데이터 타입이 올바르지 않으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenWhenPagingParameterTypeMismatch() throws Exception { + // Given + String page = "test"; + String size = "test"; + + // when + ResultActions resultActions = getResultActions(accessToken, 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(accessToken, 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("PathVariable 여행 리포트 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTripReportIdTypeMismatch() throws Exception { + // given + String invalidId = "abc"; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidId, 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("여행 리포트의 소유자가 아니라면 403 Forbidden을 반환한다.") + void shouldReturnForbiddenWhenNotTripReportOwner() throws Exception { + // when + ResultActions resultActions = + getResultActions( + newAccessToken, tripReport.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // then + resultActions + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + TripReportErrorCode.NOT_TRIP_REPORT_OWNER + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(TripReportErrorCode.NOT_TRIP_REPORT_OWNER.getMessage())); + } + + @Test + @DisplayName("유효하지 않은 여행 리포트 ID가 들어오면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenTripReportIdIsInvalid() throws Exception { + // given + Long invalidId = -1L; + + // when + ResultActions resultActions = + getResultActions(accessToken, invalidId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + + // when & then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + TripReportErrorCode.TRIP_REPORT_NOT_FOUND + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(TripReportErrorCode.TRIP_REPORT_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 여행 리포트 ID가 들어오면 여행 리포트를 반환한다.") + void shouldReturnTripReportWhenTripReportIdIsValid() throws Exception { + // given + Long tripReportId = tripReport.getId(); + + // when + ResultActions resultActions = getResultActions(accessToken, tripReportId, "1", "10"); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.tripReportId").value(tripReportId)) + .andExpect(jsonPath("$.data.title").value(tripReport.getTitle())) + .andExpect(jsonPath("$.data.content").value(tripReport.getContent())) + .andExpect(jsonPath("$.data.startDate").value(tripReport.getStartDate())) + .andExpect(jsonPath("$.data.endDate").value(tripReport.getEndDate())) + .andExpect( + jsonPath("$.data.totalFocusHours") + .value(tripReport.getTotalFocusHours())) + .andExpect( + jsonPath("$.data.completedMissionCount") + .value(tripReport.getCompletedMissionCount())) + .andExpect(jsonPath("$.data.studyDays").value(tripReport.getStudyDays())) + .andExpect(jsonPath("$.data.imageTitle").value(tripReport.getImageTitle())) + .andExpect(jsonPath("$.data.imageUrl").value(tripReport.getImageUrl())) + .andExpect(jsonPath("$.data.history").exists()) + .andExpect(jsonPath("$.data.history.studyLogs").isArray()) + .andExpect(jsonPath("$.data.history.hasNext").isBoolean()); + } + } + + @Nested + @DisplayName("여행 리포트 생성 API") + class CreateTripReport { + private final CreateTripReportRequestFixture fixture = new CreateTripReportRequestFixture(); + + private ResultActions getResultActions(String accessToken, CreateTripReportRequest request) + throws Exception { + return mockMvc.perform( + post("/api/trip-reports") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + CreateTripReportRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions("", request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("요청한 학습 로그 ID 목록으로 해당 학습 로그를 조회하고, 하나라도 일치하지 않는 경우 404 NotFound를 반환한다.") + void shouldReturnNotFoundWhenAnyStudyLogIdDoesNotExist() throws Exception { + // given + CreateTripReportRequest request = + fixture.withStudyLogIds(List.of(studyLog1.getId(), -1L)).build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StudyLogErrorCode.STUDY_LOG_NOT_FOUND + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(StudyLogErrorCode.STUDY_LOG_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("조회된 학습 로그 목록 중 삭제된 학습 로그가 존재하면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenStudyLogAlreadyDeleted() throws Exception { + // given + studyLog1.updateDeletedAt(); + List studyLogIds = List.of(studyLog1.getId(), studyLog2.getId()); + CreateTripReportRequest request = fixture.withStudyLogIds(studyLogIds).build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED + .getMessage())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 여행 리포트를 생성하고 반환한다.") + void shouldCreateTripReportWhenRequestIsValid() throws Exception { + // given + List studyLogIds = List.of(studyLog1.getId(), studyLog2.getId()); + CreateTripReportRequest request = fixture.withStudyLogIds(studyLogIds).build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.tripReportId").isNumber()); + } + } + + @Nested + @DisplayName("여행 리포트 이미지 Presigned URL 발급 API") + class IssuePresignedUrl { + private static final String PRESIGNED_URL = "/api/trip-reports/%d/images/presigned"; + + private final PresignTripReportImageRequestFixture fixture = + new PresignTripReportImageRequestFixture(); + + private ResultActions getResultActions( + String accessToken, Long tripReportId, PresignTripReportImageRequest request) + throws Exception { + return mockMvc.perform( + post(String.format(PRESIGNED_URL, tripReportId)) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + PresignTripReportImageRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions("", tripReport.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenFilenameIsEmpty() throws Exception { + // given + PresignTripReportImageRequest request = fixture.withOriginFilename("").build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, tripReport.getId(), request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenExtensionIsInvalid() throws Exception { + // given + PresignTripReportImageRequest request = fixture.withOriginFilename("test.pdf").build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, tripReport.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + ImageErrorCode.INVALID_IMAGE_EXTENSION + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage())); + } + + @Test + @DisplayName("유효한 파일명으로 Presigned URL을 발급한다.") + void shouldIssuePresignedUrlWhenFilenameIsValid() throws Exception { + // given + PresignTripReportImageRequest request = fixture.build(); + given(s3ImageStorageProvider.issuePresignedUrl(anyString())) + .willReturn("https://mocked-presigned-url.com"); + + // when + ResultActions resultActions = + getResultActions(accessToken, tripReport.getId(), request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty()) + .andExpect(jsonPath("$.data.tmpKey").isNotEmpty()) + .andExpect( + jsonPath("$.data.tmpKey") + .value(Matchers.startsWith("tmp/trip-reports/"))) + .andExpect( + jsonPath("$.data.tmpKey") + .value(Matchers.containsString(tripReport.getId().toString()))); + + verify(s3ImageStorageProvider).issuePresignedUrl(anyString()); + } + } + + @Nested + @DisplayName("여행 리포트 이미지 확정 API") + class ConfirmImage { + private static final String CONFIRM_URL = "/api/trip-reports/%d/images/confirm"; + + private final ConfirmTripReportImageRequestFixture fixture = + new ConfirmTripReportImageRequestFixture(); + + private ResultActions getResultActions( + String accessToken, Long tripReportId, ConfirmTripReportImageRequest request) + throws Exception { + return mockMvc.perform( + post(String.format(CONFIRM_URL, tripReportId)) + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + ConfirmTripReportImageRequest request = fixture.build(); + + // when + ResultActions resultActions = getResultActions("", tripReport.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenTmpKeyIsEmpty() throws Exception { + // given + ConfirmTripReportImageRequest request = fixture.withTmpKey("").build(); + + // when + ResultActions resultActions = + getResultActions(accessToken, tripReport.getId(), request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + } +}