diff --git a/build.gradle b/build.gradle index adce32d..dd9f0b4 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,12 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.0' id 'io.spring.dependency-management' version '1.1.7' - id 'com.diffplug.spotless' version '6.21.0' + id 'com.diffplug.spotless' version '8.1.0' id 'org.flywaydb.flyway' version '11.11.2' - id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.jvm' version '2.2.21' + id 'org.jetbrains.kotlin.plugin.spring' version '2.2.21' + id 'org.jetbrains.kotlin.plugin.jpa' version '2.2.21' +// id 'org.jetbrains.kotlin.kapt' version '2.2.21' } group = 'com.ject' @@ -52,6 +55,11 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // Querydsl (Kotlin용 – kapt 추가, Java용 Querydsl과 충돌이 발생하여 주석 처리) +// kapt "com.querydsl:querydsl-apt:5.0.0:jakarta" +// kapt "jakarta.annotation:jakarta.annotation-api" +// kapt "jakarta.persistence:jakarta.persistence-api" + // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -71,6 +79,12 @@ dependencies { // Tika : 이미지 타입 검사 implementation 'org.apache.tika:tika-core:2.5.0' + // Kotlin + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + implementation "org.jetbrains.kotlin:kotlin-reflect" + implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + testImplementation "org.jetbrains.kotlin:kotlin-test" + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.batch:spring-batch-test' @@ -78,7 +92,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" + testImplementation "org.mockito.kotlin:mockito-kotlin:6.1.0" } jar.enabled = false // 일반 JAR 파일 생성 비활성화 @@ -96,6 +110,13 @@ spotless { endWithNewline() // 파일 끝부분 개행 처리 googleJavaFormat().aosp() // google java format 스타일 적용 } + + kotlin { + target("**/*.kt") // 모든 .kt 파일에 적용 + ktlint("1.8.0") // Kotlin 코드 스타일 검사 및 자동 포맷팅 + trimTrailingWhitespace() // 공백 제거 + endWithNewline() // 파일 끝부분 개행 처리 + } } // pre-commit spotless check script diff --git a/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java index b650e60..9679211 100644 --- a/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java +++ b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java @@ -4,14 +4,12 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; -import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @EntityListeners(AuditingEntityListener.class) @MappedSuperclass -@Getter public abstract class BaseTimeEntity { @CreatedDate @@ -21,4 +19,20 @@ public abstract class BaseTimeEntity { @LastModifiedDate private LocalDateTime updatedAt; protected LocalDateTime deletedAt; + + public boolean isDeleted() { + return deletedAt != null; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } } diff --git a/src/main/java/com/ject/studytrip/member/domain/model/Member.java b/src/main/java/com/ject/studytrip/member/domain/model/Member.java index 0364941..51ac19f 100644 --- a/src/main/java/com/ject/studytrip/member/domain/model/Member.java +++ b/src/main/java/com/ject/studytrip/member/domain/model/Member.java @@ -10,7 +10,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class Member extends BaseTimeEntity { @@ -74,4 +73,36 @@ public void updateProfileImage(String profileImage) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public MemberCategory getCategory() { + return category; + } + + public String getEmail() { + return email; + } + + public Long getId() { + return id; + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } + + public MemberRole getRole() { + return role; + } + + public String getSocialId() { + return socialId; + } + + public SocialProvider getSocialProvider() { + return socialProvider; + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java b/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java index 03be38d..d337819 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java +++ b/src/main/java/com/ject/studytrip/mission/domain/model/DailyMission.java @@ -9,7 +9,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class DailyMission extends BaseTimeEntity { @@ -32,4 +31,16 @@ public static DailyMission of(Mission mission, DailyGoal dailyGoal) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public DailyGoal getDailyGoal() { + return dailyGoal; + } + + public Long getId() { + return id; + } + + public Mission getMission() { + return mission; + } } diff --git a/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java b/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java index 1f8b0d7..c5adf5c 100644 --- a/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java +++ b/src/main/java/com/ject/studytrip/mission/domain/model/Mission.java @@ -11,7 +11,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class Mission extends BaseTimeEntity { @@ -43,4 +42,20 @@ public void updateDeletedAt() { public void updateCompleted() { this.completed = true; } + + public boolean isCompleted() { + return completed; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Stamp getStamp() { + return stamp; + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java b/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java deleted file mode 100644 index d220335..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ject.studytrip.pomodoro.application.dto; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; - -public record PomodoroInfo(Long pomodoroId, int focusDurationInMinute, int focusSessionCount) { - public static PomodoroInfo from(Pomodoro pomodoro) { - return new PomodoroInfo( - pomodoro.getId(), - pomodoro.getFocusDurationInSeconds() / 60, - pomodoro.getFocusSessionCount()); - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java b/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java deleted file mode 100644 index 0c6fca9..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.ject.studytrip.pomodoro.application.service; - -import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.domain.policy.PomodoroPolicy; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroCommandRepository; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; -import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PomodoroCommandService { - private final PomodoroRepository pomodoroRepository; - private final PomodoroCommandRepository pomodoroCommandRepository; - - public Pomodoro createPomodoro(DailyGoal dailyGoal, CreatePomodoroRequest request) { - int focusDurationInSeconds = request.focusDurationInMinute() * 60; - - Pomodoro pomodoro = - PomodoroFactory.create( - dailyGoal, focusDurationInSeconds, request.focusSessionCount(), 0); - - return pomodoroRepository.save(pomodoro); - } - - public void updateTotalFocusTime(Pomodoro pomodoro, int totalFocusTimeInSeconds) { - PomodoroPolicy.validateTotalFocusTimeNotNegative(totalFocusTimeInSeconds); - - pomodoro.updateTotalFocusTimeInSeconds(totalFocusTimeInSeconds); - } - - public void deletePomodoro(Pomodoro pomodoro) { - pomodoro.updateDeletedAt(); - } - - public long hardDeletePomodoros() { - return pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull(); - } - - public long hardDeletePomodorosOwnedByDeletedDailyGoal() { - return pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner(); - } - - public long hardDeletePomodorosByMember(Long memberId) { - return pomodoroCommandRepository.deleteAllByMemberId(memberId); - } -} 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 deleted file mode 100644 index 112e1f7..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.ject.studytrip.pomodoro.application.service; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; -import com.ject.studytrip.pomodoro.domain.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; - -@Service -@RequiredArgsConstructor -public class PomodoroQueryService { - private final PomodoroRepository pomodoroRepository; - private final PomodoroQueryRepository pomodoroQueryRepository; - - public Pomodoro getValidPomodoroByDailyGoal(Long dailyGoalId) { - Pomodoro pomodoro = - pomodoroRepository - .findByDailyGoalId(dailyGoalId) - .orElseThrow( - () -> new CustomException(PomodoroErrorCode.POMODORO_NOT_FOUND)); - - PomodoroPolicy.validateNotDeleted(pomodoro); - - return pomodoro; - } - - public long getTotalFocusHoursByTripId(Long tripId) { - return pomodoroQueryRepository.sumFocusHoursByTripId(tripId); - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java b/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java deleted file mode 100644 index 77b07f5..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.ject.studytrip.pomodoro.domain.error; - -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum PomodoroErrorCode implements ErrorCode { - // 400 - POMODORO_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 뽀모도로입니다."), - POMODORO_NEGATIVE_FOCUS_TIME(HttpStatus.BAD_REQUEST, "뽀모도로 총 집중시간(분)은 음수일 수 없습니다."), - - // 404 - POMODORO_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 뽀모도로 정보를 찾을 수 없습니다."), - ; - - private final HttpStatus status; - private final String message; - - @Override - public String getName() { - return this.name(); - } - - @Override - public HttpStatus getStatus() { - return this.status; - } - - @Override - public String getMessage() { - return this.message; - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.java b/src/main/java/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.java deleted file mode 100644 index 75588a1..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ject.studytrip.pomodoro.domain.factory; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class PomodoroFactory { - public static Pomodoro create( - DailyGoal dailyGoal, - int focusDurationInSeconds, - int focusSessionCount, - int breakDurationInSeconds) { - return Pomodoro.of( - dailyGoal, focusDurationInSeconds, focusSessionCount, breakDurationInSeconds); - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java b/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java index f7ad141..0b0e9fb 100644 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java +++ b/src/main/java/com/ject/studytrip/pomodoro/domain/model/Pomodoro.java @@ -9,7 +9,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class Pomodoro extends BaseTimeEntity { @@ -49,4 +48,28 @@ public void updateTotalFocusTimeInSeconds(int totalFocusTimeInSeconds) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public Long getId() { + return id; + } + + public DailyGoal getDailyGoal() { + return dailyGoal; + } + + public int getFocusDurationInSeconds() { + return focusDurationInSeconds; + } + + public int getFocusSessionCount() { + return focusSessionCount; + } + + public int getBreakDurationInSeconds() { + return breakDurationInSeconds; + } + + public int getTotalFocusTimeInSeconds() { + return totalFocusTimeInSeconds; + } } diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java b/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java deleted file mode 100644 index 7f3c8be..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ject.studytrip.pomodoro.domain.policy; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class PomodoroPolicy { - public static void validateNotDeleted(Pomodoro pomodoro) { - if (pomodoro.getDeletedAt() != null) - throw new CustomException(PomodoroErrorCode.POMODORO_ALREADY_DELETED); - } - - public static void validateTotalFocusTimeNotNegative(int totalFocusTime) { - if (totalFocusTime < 0) { - throw new CustomException(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME); - } - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java b/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java deleted file mode 100644 index a1ba2eb..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ject.studytrip.pomodoro.domain.repository; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import java.util.Optional; - -public interface PomodoroRepository { - Pomodoro save(Pomodoro pomodoro); - - Optional findByDailyGoalId(Long dailyGoalId); -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java deleted file mode 100644 index ad47237..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ject.studytrip.pomodoro.infra.jpa; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PomodoroJpaRepository extends JpaRepository { - - Optional findByDailyGoalId(Long dailyGoalId); -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java b/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java deleted file mode 100644 index d3371b0..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ject.studytrip.pomodoro.infra.jpa; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class PomodoroRepositoryAdapter implements PomodoroRepository { - private final PomodoroJpaRepository pomodoroJpaRepository; - - @Override - public Pomodoro save(Pomodoro pomodoro) { - return pomodoroJpaRepository.save(pomodoro); - } - - @Override - public Optional findByDailyGoalId(Long dailyGoalId) { - return pomodoroJpaRepository.findByDailyGoalId(dailyGoalId); - } -} diff --git a/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java b/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java deleted file mode 100644 index 92add8f..0000000 --- a/src/main/java/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ject.studytrip.pomodoro.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; - -public record CreatePomodoroRequest( - @Schema(description = "집중 시간(분)") @Min(value = 1, message = "뽀모도로 최소 집중 시간은 1분입니다.") - int focusDurationInMinute, - @Schema(description = "집중 세션 개수") @Min(value = 1, message = "뽀모도로 최소 집중 세션 개수는 1개입니다.") - int focusSessionCount) {} diff --git a/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java b/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java index 6921a97..30e974f 100644 --- a/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java +++ b/src/main/java/com/ject/studytrip/stamp/application/service/StampCommandService.java @@ -35,17 +35,18 @@ public void createStamps(Trip trip, int nextOrder, List requ final List stamps = switch (trip.getCategory()) { - // 탐험형 여행일 경우 - // order 0 으로 전부 고정 - case EXPLORE -> requests.stream() - .map( - stamp -> - StampFactory.create( - trip, stamp.name(), 0, stamp.endDate())) - .toList(); - - // 코스형 여행일 경우 - // nextOrder 부터 1씩 증가하며 order 저장 + // 탐험형 여행일 경우 + // order 0 으로 전부 고정 + case EXPLORE -> + requests.stream() + .map( + stamp -> + StampFactory.create( + trip, stamp.name(), 0, stamp.endDate())) + .toList(); + + // 코스형 여행일 경우 + // nextOrder 부터 1씩 증가하며 order 저장 case COURSE -> { int order = nextOrder; diff --git a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java index 0689014..0b84a16 100644 --- a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java +++ b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java @@ -12,7 +12,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class Stamp extends BaseTimeEntity { @@ -80,4 +79,36 @@ public void decreaseTotalMissions() { public void increaseCompletedMissions(int count) { this.completedMissions += count; } + + public boolean isCompleted() { + return completed; + } + + public int getCompletedMissions() { + return completedMissions; + } + + public LocalDate getEndDate() { + return endDate; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public int getStampOrder() { + return stampOrder; + } + + public int getTotalMissions() { + return totalMissions; + } + + public Trip getTrip() { + return trip; + } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java deleted file mode 100644 index 9a6f728..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.studylog.application.dto; - -public record PresignedStudyLogImageInfo(Long studyLogId, String tmpKey, String presignedUrl) { - public static PresignedStudyLogImageInfo of( - Long studyLogId, String tmpKey, String presignedUrl) { - return new PresignedStudyLogImageInfo(studyLogId, tmpKey, presignedUrl); - } -} 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 deleted file mode 100644 index 2940129..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogDetail.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ject.studytrip.studylog.application.dto; - -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; -import java.util.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), - safeStudyLogDailyMissions.stream().map(StudyLogDailyMissionInfo::from).toList()); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java deleted file mode 100644 index 5ae81d2..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ject.studytrip.studylog.application.dto; - -import com.ject.studytrip.global.util.DateUtil; -import com.ject.studytrip.studylog.domain.model.StudyLog; - -public record StudyLogInfo( - Long studyLogId, - String title, - String content, - String imageUrl, - String createdAt, - String updatedAt, - String deletedAt) { - public static StudyLogInfo from(StudyLog studyLog) { - return new StudyLogInfo( - studyLog.getId(), - studyLog.getTitle(), - studyLog.getContent(), - studyLog.getImageUrl(), - DateUtil.formatDateTime(studyLog.getCreatedAt()), - DateUtil.formatDateTime(studyLog.getUpdatedAt()), - DateUtil.formatDateTime(studyLog.getDeletedAt())); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.java deleted file mode 100644 index a8290ad..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.studylog.application.dto; - -import java.util.List; - -public record StudyLogSliceInfo(List studyLogDetails, boolean hasNext) { - public static StudyLogSliceInfo of(List studyLogDetails, boolean hasNext) { - return new StudyLogSliceInfo(studyLogDetails, hasNext); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java deleted file mode 100644 index 4b0f660..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.ject.studytrip.studylog.application.facade; - -import static com.ject.studytrip.global.common.constants.CacheNameConstants.*; - -import com.ject.studytrip.image.application.dto.PresignedImageInfo; -import com.ject.studytrip.image.application.service.ImageService; -import com.ject.studytrip.mission.application.service.DailyMissionQueryService; -import com.ject.studytrip.mission.application.service.MissionCommandService; -import com.ject.studytrip.mission.domain.model.DailyMission; -import com.ject.studytrip.mission.domain.model.Mission; -import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService; -import com.ject.studytrip.pomodoro.application.service.PomodoroQueryService; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.stamp.application.service.StampCommandService; -import com.ject.studytrip.stamp.domain.model.Stamp; -import com.ject.studytrip.studylog.application.dto.*; -import com.ject.studytrip.studylog.application.service.*; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; -import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; -import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; -import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; -import com.ject.studytrip.trip.application.service.DailyGoalQueryService; -import com.ject.studytrip.trip.application.service.TripQueryService; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import java.util.*; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.cache.annotation.Caching; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class StudyLogFacade { - private static final String STUDY_LOG_IMAGE_KEY_PREFIX = "study-logs"; - - private final TripQueryService tripQueryService; - private final StudyLogQueryService studyLogQueryService; - private final DailyMissionQueryService dailyMissionQueryService; - private final StudyLogDailyMissionQueryService studyLogDailyMissionQueryService; - private final DailyGoalQueryService dailyGoalQueryService; - private final PomodoroQueryService pomodoroQueryService; - - private final StampCommandService stampCommandService; - private final MissionCommandService missionCommandService; - private final StudyLogCommandService studyLogCommandService; - private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService; - private final PomodoroCommandService pomodoroCommandService; - - private final ImageService imageService; - - @Caching( - evict = { - @CacheEvict(cacheNames = STUDY_LOGS, allEntries = true), - @CacheEvict(cacheNames = MISSIONS, allEntries = true), - @CacheEvict(cacheNames = STAMP, allEntries = true), - @CacheEvict( - cacheNames = STAMPS, - key = - "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).stamps(#memberId, #tripId)") - }) - @Transactional - public StudyLogInfo createStudyLog( - Long memberId, Long tripId, Long dailyGoalId, CreateStudyLogRequest request) { - // 1. 유효성 검증 및 엔티티 조회 - Trip trip = tripQueryService.getValidTrip(memberId, tripId); - DailyGoal dailyGoal = dailyGoalQueryService.getValidDailyGoal(trip.getId(), dailyGoalId); - List selectedDailyMissions = - dailyMissionQueryService.getValidDailyMissionsWithMissionAndStampByIds( - dailyGoal.getId(), request.selectedDailyMissionIds()); - Pomodoro pomodoro = pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId); - - // 2. 학습 로그 생성 - StudyLog studyLog = - studyLogCommandService.createStudyLog( - trip.getMember(), dailyGoal, request.content()); - - // 3. 뽀모도로 총 학습시간 업데이트 - pomodoroCommandService.updateTotalFocusTime(pomodoro, request.totalFocusTimeInSeconds()); - - // 4. 연관 데이터 생성 및 미션 완료 처리 - createStudyLogDailyMissionsAndCompleteMissions(studyLog, selectedDailyMissions); - - return StudyLogInfo.from(studyLog); - } - - @Cacheable( - cacheNames = STUDY_LOGS, - key = - "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).studyLogs(#memberId, #tripId, #page, #size, #order)") - @Transactional(readOnly = true) - public StudyLogSliceInfo getStudyLogsByTrip( - Long memberId, Long tripId, int page, int size, String order) { - // 1. 유효성 검증 및 엔티티 조회 - Trip trip = tripQueryService.getValidTrip(memberId, tripId); - - // 2. 페이징된 학습 로그 목록 조회 - Slice studyLogSlice = - studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size, order); - - // 3. 학습 로그 상세 정보 구성 - return buildStudyLogDetailsSlice(studyLogSlice); - } - - @Transactional(readOnly = true) - public PresignedStudyLogImageInfo issuePresignedUrl( - Long studyLogId, PresignStudyLogImageRequest request) { - StudyLog studyLog = studyLogQueryService.getValidStudyLog(studyLogId); - PresignedImageInfo info = - imageService.presign( - STUDY_LOG_IMAGE_KEY_PREFIX, - studyLog.getId().toString(), - request.originFilename()); - - return PresignedStudyLogImageInfo.of(studyLog.getId(), info.tmpKey(), info.presignedUrl()); - } - - @CacheEvict(cacheNames = STUDY_LOGS, allEntries = true) - @Transactional - public void confirmImage(Long studyLogId, ConfirmStudyLogImageRequest request) { - StudyLog studyLog = studyLogQueryService.getValidStudyLog(studyLogId); - String imageUrl = imageService.confirm(request.tmpKey()); - - studyLogCommandService.updateImageUrl(studyLog, imageUrl); - } - - private void createStudyLogDailyMissionsAndCompleteMissions( - StudyLog studyLog, List selectedDailyMissions) { - // 학습 로그 데일리 미션 저장 - studyLogDailyMissionCommandService.createStudyLogDailyMissions( - studyLog, selectedDailyMissions); - - List missions = - selectedDailyMissions.stream().map(DailyMission::getMission).toList(); - - // 스탬프 ID를 기준으로 Stamp 집계 - Map stampById = new HashMap<>(); - - // 스탬프 ID를 기준으로 완료된 미션 수 집계 - Map completeMissionCountByStampId = new HashMap<>(); - - missions.forEach( - mission -> { - Stamp stamp = mission.getStamp(); - stampById.putIfAbsent(stamp.getId(), stamp); - - // 미션 완료 처리 - missionCommandService.completeMission(mission); - - // 스탬프별 완료한 미션 개수 누적(없으면 1, 있으면 +1) - completeMissionCountByStampId.merge(stamp.getId(), 1, Integer::sum); - }); - - // 스탬프별 완료된 미션 수 증가 - completeMissionCountByStampId.forEach( - (stampId, completeMissions) -> { - Stamp stamp = stampById.get(stampId); - stampCommandService.increaseCompletedMissions(stamp, completeMissions); - }); - - // NOTE: 현재는 데이터/트래픽이 적어 단건씩 처리 + 증분 갱신 방법 사용 - // 동시성/규모가 커지면 벌크 완료 + 스탬프의 완료된 미션 수 재계산으로 리팩토링도 가능할 것 같음 - } - - 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/studylog/application/service/StudyLogCommandService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java deleted file mode 100644 index fbdb689..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogCommandService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.ject.studytrip.studylog.application.service; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.studylog.domain.factory.StudyLogFactory; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.policy.StudyLogPolicy; -import com.ject.studytrip.studylog.domain.repository.StudyLogCommandRepository; -import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class StudyLogCommandService { - private final StudyLogRepository studyLogRepository; - private final StudyLogCommandRepository studyLogCommandRepository; - - public StudyLog createStudyLog(Member member, DailyGoal dailyGoal, String content) { - StudyLog studyLog = StudyLogFactory.create(member, dailyGoal, content); - - return studyLogRepository.save(studyLog); - } - - public long hardDeleteStudyLogs() { - return studyLogCommandRepository.deleteAllByDeletedAtIsNotNull(); - } - - public long hardDeleteStudyLogsOwnedByDeletedMember() { - return studyLogCommandRepository.deleteAllByDeletedMemberOwner(); - } - - public long hardDeleteStudyLogsOwnedByDeletedDailyGoal() { - return studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner(); - } - - public void updateImageUrl(StudyLog studyLog, String imageUrl) { - StudyLogPolicy.validateNotDeleted(studyLog); - - studyLog.updateImageUrl(imageUrl); - } - - public long hardDeleteStudyLogsByMember(Long memberId) { - return studyLogCommandRepository.deleteByMemberId(memberId); - } -} 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 deleted file mode 100644 index 08700c2..0000000 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.ject.studytrip.studylog.application.service; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; -import com.ject.studytrip.studylog.domain.model.StudyLog; -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; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class StudyLogQueryService { - private final StudyLogRepository studyLogRepository; - private final StudyLogQueryRepository studyLogQueryRepository; - - public long getActiveStudyLogCountByMemberId(Long memberId) { - return studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId); - } - - public Slice getStudyLogsSliceByTripId( - Long tripId, int page, int size, String order) { - return studyLogQueryRepository.findSliceByTripId(tripId, PageRequest.of(page, size), order); - } - - public StudyLog getValidStudyLog(Long studyLogId) { - StudyLog studyLog = - studyLogRepository - .findById(studyLogId) - .orElseThrow( - () -> new CustomException(StudyLogErrorCode.STUDY_LOG_NOT_FOUND)); - - StudyLogPolicy.validateNotDeleted(studyLog); - - return studyLog; - } - - public List getValidStudyLogs(List studyLogIds) { - List studyLogs = studyLogRepository.findAllByIdIn(studyLogIds); - - StudyLogPolicy.validateExistAll(studyLogs, studyLogIds); - studyLogs.forEach(StudyLogPolicy::validateNotDeleted); - - return studyLogs; - } - - public long getStudyLogCountByTripId(Long tripId) { - return studyLogQueryRepository.countStudyLogsByTripId(tripId); - } - - public Slice getStudyLogsSliceByTripReportId(Long tripReportId, int page, int size) { - return studyLogQueryRepository.findSliceByTripReportIdOrderByCreatedAtDesc( - tripReportId, PageRequest.of(page, size)); - } - - public List getStudyLogIdsByTripId(Long tripId) { - return studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId); - } - - public List getStudyLogImageUrlsByMemberId(Long memberId) { - return studyLogQueryRepository.findImageUrlsByMemberId(memberId); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java b/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java deleted file mode 100644 index 1339430..0000000 --- a/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.ject.studytrip.studylog.domain.error; - -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum StudyLogErrorCode implements ErrorCode { - // 400 - STUDY_LOG_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 학습 로그입니다."), - - // 404 - STUDY_LOG_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/studylog/domain/factory/StudyLogFactory.java b/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java deleted file mode 100644 index 2490c09..0000000 --- a/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ject.studytrip.studylog.domain.factory; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class StudyLogFactory { - public static StudyLog create(Member member, DailyGoal dailyGoal, String content) { - return StudyLog.of(member, dailyGoal, content); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java index 27174b9..7d6bd3c 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java @@ -12,7 +12,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class StudyLog extends BaseTimeEntity { @@ -53,4 +52,28 @@ public void updateImageUrl(String imageUrl) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public DailyGoal getDailyGoal() { + return dailyGoal; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public String getImageUrl() { + return imageUrl; + } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java index 8db3b17..0cce2cd 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLogDailyMission.java @@ -8,7 +8,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class StudyLogDailyMission extends BaseTimeEntity { @@ -27,4 +26,16 @@ public class StudyLogDailyMission extends BaseTimeEntity { public static StudyLogDailyMission of(StudyLog studyLog, DailyMission dailyMission) { return StudyLogDailyMission.builder().studyLog(studyLog).dailyMission(dailyMission).build(); } + + public StudyLog getStudyLog() { + return studyLog; + } + + public Long getId() { + return id; + } + + public DailyMission getDailyMission() { + return dailyMission; + } } 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 deleted file mode 100644 index 17005fe..0000000 --- a/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ject.studytrip.studylog.domain.policy; - -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; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class StudyLogPolicy { - public static void validateNotDeleted(StudyLog studyLog) { - if (studyLog.getDeletedAt() != null) { - 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/StudyLogRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java deleted file mode 100644 index 4991c88..0000000 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -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 { - - 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 deleted file mode 100644 index 4d203f1..0000000 --- a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -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 { - 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 deleted file mode 100644 index e396e36..0000000 --- a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.ject.studytrip.studylog.infra.jpa; - -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; - -@Repository -@RequiredArgsConstructor -public class StudyLogRepositoryAdapter implements StudyLogRepository { - private final StudyLogJpaRepository studyLogJpaRepository; - - @Override - public StudyLog save(StudyLog studyLog) { - return studyLogJpaRepository.save(studyLog); - } - - @Override - 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/presentation/dto/request/ConfirmStudyLogImageRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java deleted file mode 100644 index c0d3a02..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; - -public record ConfirmStudyLogImageRequest( - @Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") - String tmpKey) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java deleted file mode 100644 index b11fcc1..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotEmpty; -import java.util.List; - -public record CreateStudyLogRequest( - @Schema(description = "뽀모도로 총 집중시간(초)") @Min(value = 0, message = "총 집중시간(초)은 음수일 수 없습니다.") - int totalFocusTimeInSeconds, - @Schema(description = "선택한 데일리 미션 ID 목록") - @NotEmpty(message = "학습로그를 작성할 데일리 미션 목록은 필수 요청 값입니다.") - List selectedDailyMissionIds, - @Schema(description = "학습 내용") String content) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java deleted file mode 100644 index 4d6e701..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; - -public record PresignStudyLogImageRequest( - @Schema(description = "원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") - String originFilename) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java deleted file mode 100644 index 461a370..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.response; - -import com.ject.studytrip.studylog.application.dto.StudyLogInfo; -import io.swagger.v3.oas.annotations.media.Schema; - -public record CreateStudyLogResponse(@Schema(description = "생성된 학습 로그 ID") Long studyLogId) { - public static CreateStudyLogResponse of(StudyLogInfo info) { - return new CreateStudyLogResponse(info.studyLogId()); - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java deleted file mode 100644 index 761c1a2..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.response; - -import com.ject.studytrip.mission.application.dto.MissionInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogDailyMissionInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogDetail; -import com.ject.studytrip.studylog.application.dto.StudyLogInfo; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -public record LoadStudyLogsSliceResponse( - @Schema(description = "학습 로그 목록") List studyLogs, - @Schema(description = "다음 데이터 존재 여부") boolean hasNext) { - public static LoadStudyLogsSliceResponse of( - List studyLogDetails, boolean hasNext) { - return new LoadStudyLogsSliceResponse( - studyLogDetails.stream() - .map( - result -> - StudyLogResponse.of( - result.studyLogInfo(), - result.studyLogDailyMissionInfos())) - .toList(), - hasNext); - } - - private record StudyLogResponse( - @Schema(description = "학습 로그 ID") Long studyLogId, - @Schema(description = "학습 로그에서 선택한 미션 목록") - List dailyMissions, - @Schema(description = "학습 로그 제목") String title, - @Schema(description = "학습 로그 내용") String content, - @Schema(description = "학습 로그 이미지 URL") String imageUrl, - @Schema(description = "학습 로그 생성날짜") String createdAt) { - private static StudyLogResponse of( - StudyLogInfo studyLogInfo, - List studyLogDailyMissionInfos) { - return new StudyLogResponse( - studyLogInfo.studyLogId(), - studyLogDailyMissionInfos.stream() - .map( - studyLogDailyMissionInfo -> - StudyLogDailyMissionResponse.of( - studyLogDailyMissionInfo, - studyLogDailyMissionInfo - .dailyMissionInfo() - .missionInfo())) - .toList(), - studyLogInfo.title(), - studyLogInfo.content(), - studyLogInfo.imageUrl(), - studyLogInfo.createdAt()); - } - - private record StudyLogDailyMissionResponse( - @Schema(description = "학습 로그 데일리 미션 ID") Long studyLogDailyMissionId, - @Schema(description = "미션 이름") String missionName) { - private static StudyLogDailyMissionResponse of( - StudyLogDailyMissionInfo studyLogDailyMissionInfo, MissionInfo missionInfo) { - return new StudyLogDailyMissionResponse( - studyLogDailyMissionInfo.studyLogDailyMissionId(), - missionInfo.missionName()); - } - } - } -} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java deleted file mode 100644 index fb35ce8..0000000 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ject.studytrip.studylog.presentation.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record PresignedStudyLogImageResponse( - @Schema(description = "학습 로그 ID") Long studyLogId, - @Schema(description = "학습 로그 이미지 임시키") String tmpKey, - @Schema(description = "학습 로그 이미지 업로드용 Presigned URL") String presignedUrl) { - public static PresignedStudyLogImageResponse of( - Long studyLogId, String tmpKey, String presignedUrl) { - return new PresignedStudyLogImageResponse(studyLogId, tmpKey, presignedUrl); - } -} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java b/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java index 37ffdb4..7bdaa5a 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/DailyGoal.java @@ -8,7 +8,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class DailyGoal extends BaseTimeEntity { @@ -36,4 +35,20 @@ public void updateDeletedAt() { public void updateCompleted() { this.completed = true; } + + public boolean isCompleted() { + return completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public Trip getTrip() { + return trip; + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java index d753761..8cf4a86 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java @@ -13,9 +13,47 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class Trip extends BaseTimeEntity { + public TripCategory getCategory() { + return category; + } + + public boolean isCompleted() { + return completed; + } + + public int getCompletedStamps() { + return completedStamps; + } + + public LocalDate getEndDate() { + return endDate; + } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public String getMemo() { + return memo; + } + + public String getName() { + return name; + } + + public LocalDate getStartDate() { + return startDate; + } + + public int getTotalStamps() { + return totalStamps; + } @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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 index db3f0d3..bf9966f 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/TripReport.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/TripReport.java @@ -11,7 +11,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class TripReport extends BaseTimeEntity { @@ -77,4 +76,48 @@ public void updateImageUrl(String imageUrl) { public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + + public long getTotalFocusHours() { + return totalFocusHours; + } + + public String getTitle() { + return title; + } + + public long getStudyLogCount() { + return studyLogCount; + } + + public long getStudyDays() { + return studyDays; + } + + public String getStartDate() { + return startDate; + } + + public Member getMember() { + return member; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getImageTitle() { + return imageTitle; + } + + public Long getId() { + return id; + } + + public String getEndDate() { + return endDate; + } + + public String getContent() { + return content; + } } 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 index d615385..c37e979 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/model/TripReportStudyLog.java +++ b/src/main/java/com/ject/studytrip/trip/domain/model/TripReportStudyLog.java @@ -10,7 +10,6 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Getter @Builder(access = AccessLevel.PRIVATE) public class TripReportStudyLog extends BaseTimeEntity { @@ -29,4 +28,16 @@ public class TripReportStudyLog extends BaseTimeEntity { public static TripReportStudyLog of(TripReport tripReport, StudyLog studyLog) { return TripReportStudyLog.builder().tripReport(tripReport).studyLog(studyLog).build(); } + + public Long getId() { + return id; + } + + public StudyLog getStudyLog() { + return studyLog; + } + + public TripReport getTripReport() { + return tripReport; + } } diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java index 5aafa03..8998616 100644 --- a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadDailyGoalDetailResponse.java @@ -31,7 +31,9 @@ public record DailyGoalPomodoroResponse( @Schema(name = "뽀모도로 집중 세션 개수") int focusSessionCount) { public static DailyGoalPomodoroResponse of(PomodoroInfo info) { return new DailyGoalPomodoroResponse( - info.pomodoroId(), info.focusDurationInMinute(), info.focusSessionCount()); + info.getPomodoroId(), + info.getFocusDurationInMinute(), + info.getFocusSessionCount()); } } 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 index 3dff5d4..1928822 100644 --- 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 @@ -31,6 +31,6 @@ public static LoadTripReportDetailResponse of( tripReportInfo.imageTitle(), tripReportInfo.imageUrl(), LoadStudyLogsSliceResponse.of( - studyLogSliceInfo.studyLogDetails(), studyLogSliceInfo.hasNext())); + studyLogSliceInfo.getStudyLogDetails(), studyLogSliceInfo.getHasNext())); } } 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 index 0b8b600..d43dda3 100644 --- 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 @@ -29,6 +29,7 @@ public static LoadTripRetrospectDetailResponse of( tripRetrospectSummary.studyDays(), tripRetrospectSummary.studyLogIds(), LoadStudyLogsSliceResponse.of( - studyLogDetailSlice.studyLogDetails(), studyLogDetailSlice.hasNext())); + studyLogDetailSlice.getStudyLogDetails(), + studyLogDetailSlice.getHasNext())); } } diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.kt new file mode 100644 index 0000000..04dbfa7 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/application/dto/PomodoroInfo.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.pomodoro.application.dto + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro + +data class PomodoroInfo( + val pomodoroId: Long, + val focusDurationInMinute: Int, + val focusSessionCount: Int, +) { + companion object { + @JvmStatic + fun from(pomodoro: Pomodoro): PomodoroInfo = + PomodoroInfo( + pomodoro.getId(), + pomodoro.getFocusDurationInSeconds() / 60, + pomodoro.getFocusSessionCount(), + ) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.kt new file mode 100644 index 0000000..15f99b0 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandService.kt @@ -0,0 +1,55 @@ +package com.ject.studytrip.pomodoro.application.service + +import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.pomodoro.domain.policy.PomodoroPolicy +import com.ject.studytrip.pomodoro.domain.repository.PomodoroCommandRepository +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.stereotype.Service + +@Service +class PomodoroCommandService( + private val pomodoroRepository: PomodoroRepository, + private val pomodoroCommandRepository: PomodoroCommandRepository, +) { + fun createPomodoro( + dailyGoal: DailyGoal, + request: CreatePomodoroRequest, + ): Pomodoro { + val focusDurationInSeconds = request.focusDurationInMinute * 60 + + val pomodoro = + PomodoroFactory.create( + dailyGoal, + focusDurationInSeconds, + request.focusSessionCount, + 0, + ) + + return pomodoroRepository.save(pomodoro) + } + + fun updateTotalFocusTime( + pomodoro: Pomodoro, + totalFocusTimeInSeconds: Int, + ) { + PomodoroPolicy.validateNotDeleted(pomodoro) + PomodoroPolicy.validateTotalFocusTimeNotNegative(totalFocusTimeInSeconds) + + pomodoro.updateTotalFocusTimeInSeconds(totalFocusTimeInSeconds) + } + + fun deletePomodoro(pomodoro: Pomodoro) { + PomodoroPolicy.validateNotDeleted(pomodoro) + + pomodoro.updateDeletedAt() + } + + fun hardDeletePomodoros(): Long = pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull() + + fun hardDeletePomodorosOwnedByDeletedDailyGoal(): Long = pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner() + + fun hardDeletePomodorosByMember(memberId: Long): Long = pomodoroCommandRepository.deleteAllByMemberId(memberId) +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.kt new file mode 100644 index 0000000..0407320 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryService.kt @@ -0,0 +1,28 @@ +package com.ject.studytrip.pomodoro.application.service + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode +import com.ject.studytrip.pomodoro.domain.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 org.springframework.stereotype.Service + +@Service +class PomodoroQueryService( + private val pomodoroRepository: PomodoroRepository, + private val pomodoroQueryRepository: PomodoroQueryRepository, +) { + fun getValidPomodoroByDailyGoal(dailyGoalId: Long): Pomodoro { + val pomodoro = + pomodoroRepository + .findByDailyGoalId(dailyGoalId) + .orElseThrow { CustomException(PomodoroErrorCode.POMODORO_NOT_FOUND) } + + PomodoroPolicy.validateNotDeleted(pomodoro) + + return pomodoro + } + + fun getTotalFocusHoursByTripId(tripId: Long): Long = pomodoroQueryRepository.sumFocusHoursByTripId(tripId) +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.kt new file mode 100644 index 0000000..fc29fae --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/error/PomodoroErrorCode.kt @@ -0,0 +1,23 @@ +package com.ject.studytrip.pomodoro.domain.error + +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class PomodoroErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 400 + POMODORO_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 뽀모도로입니다."), + POMODORO_NEGATIVE_FOCUS_TIME(HttpStatus.BAD_REQUEST, "뽀모도로 총 집중시간(분)은 음수일 수 없습니다."), + + // 404 + POMODORO_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 뽀모도로 정보를 찾을 수 없습니다."), + ; + + override fun getName(): String = name + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.kt new file mode 100644 index 0000000..d9ad059 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/factory/PomodoroFactory.kt @@ -0,0 +1,13 @@ +package com.ject.studytrip.pomodoro.domain.factory + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.trip.domain.model.DailyGoal + +object PomodoroFactory { + fun create( + dailyGoal: DailyGoal, + focusDurationInSeconds: Int, + focusSessionCount: Int, + breakDurationInSeconds: Int, + ): Pomodoro = Pomodoro.of(dailyGoal, focusDurationInSeconds, focusSessionCount, breakDurationInSeconds) +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.kt new file mode 100644 index 0000000..4e0beef --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/policy/PomodoroPolicy.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.pomodoro.domain.policy + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode +import com.ject.studytrip.pomodoro.domain.model.Pomodoro + +object PomodoroPolicy { + fun validateNotDeleted(pomodoro: Pomodoro) { + if (pomodoro.isDeleted()) { + throw CustomException(PomodoroErrorCode.POMODORO_ALREADY_DELETED) + } + } + + fun validateTotalFocusTimeNotNegative(totalFocusTime: Int) { + if (totalFocusTime < 0) { + throw CustomException(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.kt new file mode 100644 index 0000000..0036645 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/domain/repository/PomodoroRepository.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.pomodoro.domain.repository + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import java.util.Optional + +interface PomodoroRepository { + fun save(pomodoro: Pomodoro): Pomodoro + + fun findByDailyGoalId(dailyGoalId: Long): Optional +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.kt new file mode 100644 index 0000000..192d66c --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroJpaRepository.kt @@ -0,0 +1,9 @@ +package com.ject.studytrip.pomodoro.infra.jpa + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface PomodoroJpaRepository : JpaRepository { + fun findByDailyGoalId(dailyGoalId: Long): Optional +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.kt new file mode 100644 index 0000000..f3dfffd --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/infra/jpa/PomodoroRepositoryAdapter.kt @@ -0,0 +1,15 @@ +package com.ject.studytrip.pomodoro.infra.jpa + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +class PomodoroRepositoryAdapter( + private val pomodoroJpaRepository: PomodoroJpaRepository, +) : PomodoroRepository { + override fun save(pomodoro: Pomodoro): Pomodoro = pomodoroJpaRepository.save(pomodoro) + + override fun findByDailyGoalId(dailyGoalId: Long): Optional = pomodoroJpaRepository.findByDailyGoalId(dailyGoalId) +} diff --git a/src/main/kotlin/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.kt b/src/main/kotlin/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.kt new file mode 100644 index 0000000..4e02947 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/pomodoro/presentation/dto/request/CreatePomodoroRequest.kt @@ -0,0 +1,13 @@ +package com.ject.studytrip.pomodoro.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Min + +data class CreatePomodoroRequest( + @field:Schema(description = "집중 시간(분)") + @field:Min(value = 1, message = "뽀모도로 최소 집중 시간은 1분입니다.") + val focusDurationInMinute: Int, + @field:Schema(description = "집중 세션 개수") + @field:Min(value = 1, message = "뽀모도로 최소 집중 세션 개수는 1개입니다.") + val focusSessionCount: Int, +) diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.kt new file mode 100644 index 0000000..e67fd79 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.kt @@ -0,0 +1,21 @@ +package com.ject.studytrip.studylog.application.dto + +data class PresignedStudyLogImageInfo( + val studyLogId: Long, + val tmpKey: String, + val presignedUrl: String, +) { + companion object { + @JvmStatic + fun of( + studyLogId: Long, + tmpKey: String, + presignedUrl: String, + ): PresignedStudyLogImageInfo = + PresignedStudyLogImageInfo( + studyLogId, + tmpKey, + presignedUrl, + ) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogDetail.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogDetail.kt new file mode 100644 index 0000000..f7848b3 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogDetail.kt @@ -0,0 +1,24 @@ +package com.ject.studytrip.studylog.application.dto + +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission + +data class StudyLogDetail( + val studyLogInfo: StudyLogInfo, + val studyLogDailyMissionInfos: List, +) { + companion object { + @JvmStatic + fun from( + studyLog: StudyLog, + studyLogDailyMissions: List?, + ): StudyLogDetail { + val studyLogDailyMissions = studyLogDailyMissions ?: emptyList() + + return StudyLogDetail( + StudyLogInfo.from(studyLog), + studyLogDailyMissions.map { StudyLogDailyMissionInfo.from(it) }, + ) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogInfo.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogInfo.kt new file mode 100644 index 0000000..1f1142e --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogInfo.kt @@ -0,0 +1,28 @@ +package com.ject.studytrip.studylog.application.dto + +import com.ject.studytrip.global.util.DateUtil +import com.ject.studytrip.studylog.domain.model.StudyLog + +data class StudyLogInfo( + val studyLogId: Long, + val title: String, + val content: String, + val imageUrl: String?, + val createdAt: String, + val updatedAt: String, + val deletedAt: String?, +) { + companion object { + @JvmStatic + fun from(studyLog: StudyLog): StudyLogInfo = + StudyLogInfo( + studyLog.getId(), + studyLog.getTitle(), + studyLog.getContent(), + studyLog.getImageUrl(), + DateUtil.formatDateTime(studyLog.getCreatedAt()), + DateUtil.formatDateTime(studyLog.getUpdatedAt()), + studyLog.getDeletedAt()?.let { DateUtil.formatDateTime(it) }, + ) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.kt new file mode 100644 index 0000000..58fa30f --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/dto/StudyLogSliceInfo.kt @@ -0,0 +1,14 @@ +package com.ject.studytrip.studylog.application.dto + +data class StudyLogSliceInfo( + val studyLogDetails: List, + val hasNext: Boolean, +) { + companion object { + @JvmStatic + fun of( + studyLogDetails: List, + hasNext: Boolean, + ): StudyLogSliceInfo = StudyLogSliceInfo(studyLogDetails, hasNext) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/facade/StudyLogFacade.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/facade/StudyLogFacade.kt new file mode 100644 index 0000000..79ef081 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/facade/StudyLogFacade.kt @@ -0,0 +1,184 @@ +package com.ject.studytrip.studylog.application.facade + +import com.ject.studytrip.global.common.constants.CacheNameConstants.MISSIONS +import com.ject.studytrip.global.common.constants.CacheNameConstants.STAMP +import com.ject.studytrip.global.common.constants.CacheNameConstants.STAMPS +import com.ject.studytrip.global.common.constants.CacheNameConstants.STUDY_LOGS +import com.ject.studytrip.image.application.service.ImageService +import com.ject.studytrip.mission.application.service.DailyMissionQueryService +import com.ject.studytrip.mission.application.service.MissionCommandService +import com.ject.studytrip.mission.domain.model.DailyMission +import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService +import com.ject.studytrip.pomodoro.application.service.PomodoroQueryService +import com.ject.studytrip.stamp.application.service.StampCommandService +import com.ject.studytrip.stamp.domain.model.Stamp +import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo +import com.ject.studytrip.studylog.application.dto.StudyLogDetail +import com.ject.studytrip.studylog.application.dto.StudyLogInfo +import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo +import com.ject.studytrip.studylog.application.service.StudyLogCommandService +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService +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.presentation.dto.request.ConfirmStudyLogImageRequest +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest +import com.ject.studytrip.trip.application.service.DailyGoalQueryService +import com.ject.studytrip.trip.application.service.TripQueryService +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.cache.annotation.Caching +import org.springframework.data.domain.Slice +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class StudyLogFacade( + // Query Service + private val tripQueryService: TripQueryService, + private val studyLogQueryService: StudyLogQueryService, + private val dailyMissionQueryService: DailyMissionQueryService, + private val studyLogDailyMissionQueryService: StudyLogDailyMissionQueryService, + private val dailyGoalQueryService: DailyGoalQueryService, + private val pomodoroQueryService: PomodoroQueryService, + // Command Service + private val stampCommandService: StampCommandService, + private val missionCommandService: MissionCommandService, + private val studyLogCommandService: StudyLogCommandService, + private val studyLogDailyMissionCommandService: StudyLogDailyMissionCommandService, + private val pomodoroCommandService: PomodoroCommandService, + // Image Service + private val imageService: ImageService, +) { + companion object { + private const val STUDY_LOG_IMAGE_KEY_PREFIX: String = "study-logs" + } + + @Caching( + evict = [ + CacheEvict(cacheNames = [STUDY_LOGS], allEntries = true), + CacheEvict(cacheNames = [MISSIONS], allEntries = true), + CacheEvict(cacheNames = [STAMP], allEntries = true), + CacheEvict( + cacheNames = [STAMPS], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).stamps(#memberId, #tripId)", + ), + ], + ) + @Transactional + fun createStudyLog( + memberId: Long, + tripId: Long, + dailyGoalId: Long, + request: CreateStudyLogRequest, + ): StudyLogInfo { + // 1. 유효성 검증 및 엔티티 조회 + val trip = tripQueryService.getValidTrip(memberId, tripId) + val dailyGoal = dailyGoalQueryService.getValidDailyGoal(trip.getId(), dailyGoalId) + val selectedDailyMissions = + dailyMissionQueryService.getValidDailyMissionsWithMissionAndStampByIds( + dailyGoal.getId(), + request.selectedDailyMissionIds, + ) + val pomodoro = pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoal.getId()) + + // 2. 학습 로그 생성 + val studyLog = studyLogCommandService.createStudyLog(trip.member, dailyGoal, request.content) + + // 3. 뽀모도로 총 학습시간 업데이트 + pomodoroCommandService.updateTotalFocusTime(pomodoro, request.totalFocusTimeInSeconds) + + // 4. 연관 데이터 생성 및 미션 완료 처리 + createStudyLogDailyMissionsAndCompleteMissions(studyLog, selectedDailyMissions) + + return StudyLogInfo.from(studyLog) + } + + @Cacheable( + cacheNames = [STUDY_LOGS], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).studyLogs(#memberId, #tripId, #page, #size, #order)", + ) + @Transactional(readOnly = true) + fun getStudyLogsByTrip( + memberId: Long, + tripId: Long, + page: Int, + size: Int, + order: String, + ): StudyLogSliceInfo { + // 1. 유효성 검증 및 엔티티 조회 + val trip = tripQueryService.getValidTrip(memberId, tripId) + + // 2. 페이징된 학습 로그 목록 조회 + val studyLogSlice = studyLogQueryService.getStudyLogsSliceByTripId(trip.id, page, size, order) + + // 3. 학습 로그 상세 정보 구성 + return buildStudyLogDetailsSlice(studyLogSlice) + } + + @Transactional(readOnly = true) + fun issuePresignedUrl( + studyLogId: Long, + request: PresignStudyLogImageRequest, + ): PresignedStudyLogImageInfo { + val studyLog = studyLogQueryService.getValidStudyLog(studyLogId) + val info = imageService.presign(STUDY_LOG_IMAGE_KEY_PREFIX, studyLog.id.toString(), request.originFilename) + + return PresignedStudyLogImageInfo.of(studyLog.id, info.tmpKey, info.presignedUrl) + } + + @CacheEvict(cacheNames = [STUDY_LOGS], allEntries = true) + @Transactional + fun confirmImage( + studyLogId: Long, + request: ConfirmStudyLogImageRequest, + ) { + val studyLog = studyLogQueryService.getValidStudyLog(studyLogId) + val imageUrl = imageService.confirm(request.tmpKey) + + studyLogCommandService.updateImageUrl(studyLog, imageUrl) + } + + private fun createStudyLogDailyMissionsAndCompleteMissions( + studyLog: StudyLog, + selectedDailyMissions: List, + ) { + // 학습 로그 데일리 미션 저장 + studyLogDailyMissionCommandService.createStudyLogDailyMissions(studyLog, selectedDailyMissions) + val missions = selectedDailyMissions.map { it.getMission() } + + // 스탬프 ID를 기준으로 Stamp 집계 + val stampById = mutableMapOf() + + // 스탬프 ID를 기준으로 완료된 미션 수 집계 + val completeMissionCountByStampId = mutableMapOf() + + missions.forEach { mission -> + val stamp = mission.getStamp() + stampById.putIfAbsent(stamp.getId(), stamp) + missionCommandService.completeMission(mission) // 미션 완료 처리 + completeMissionCountByStampId.merge(stamp.getId(), 1) { a, b -> a + b } // 스탬프별 완료한 미션 개수 누적(없으면 1, 있으면 +1) + } + + // 스탬프별 완료된 미션 수 증가 + completeMissionCountByStampId.forEach { (stampId, completeMissions) -> + stampById[stampId]?.let { stamp -> + stampCommandService.increaseCompletedMissions(stamp, completeMissions) + } + } + + // NOTE: 현재는 데이터/트래픽이 적어 단건씩 처리 + 증분 갱신 방법 사용 + // 동시성/규모가 커지면 벌크 완료 + 스탬프의 완료된 미션 수 재계산으로 리팩토링도 가능할 것 같음 + } + + private fun buildStudyLogDetailsSlice(studyLogSlice: Slice): StudyLogSliceInfo { + val studyLogIds = studyLogSlice.content.map { it.id } + + // 학습 로그별 학습 로그 데일리 미션 목록 그룹화 + val groupedStudyLogDailyMissions = studyLogDailyMissionQueryService.getGroupedStudyLogDailyMissionsByStudyLogIds(studyLogIds) + val studyLogDetails = studyLogSlice.content.map { StudyLogDetail.from(it, groupedStudyLogDailyMissions[it.id]) } + + return StudyLogSliceInfo.of(studyLogDetails, studyLogSlice.hasNext()) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandService.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandService.kt new file mode 100644 index 0000000..1f988e6 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandService.kt @@ -0,0 +1,43 @@ +package com.ject.studytrip.studylog.application.service + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.studylog.domain.factory.StudyLogFactory +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.policy.StudyLogPolicy +import com.ject.studytrip.studylog.domain.repository.StudyLogCommandRepository +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.stereotype.Service + +@Service +class StudyLogCommandService( + private val studyLogRepository: StudyLogRepository, + private val studyLogCommandRepository: StudyLogCommandRepository, +) { + fun createStudyLog( + member: Member, + dailyGoal: DailyGoal, + content: String, + ): StudyLog { + val studyLog = StudyLogFactory.create(member, dailyGoal, content) + + return studyLogRepository.save(studyLog) + } + + fun updateImageUrl( + studyLog: StudyLog, + imageUrl: String, + ) { + StudyLogPolicy.validateNotDeleted(studyLog) + + studyLog.updateImageUrl(imageUrl) + } + + fun hardDeleteStudyLogs(): Long = studyLogCommandRepository.deleteAllByDeletedAtIsNotNull() + + fun hardDeleteStudyLogsOwnedByDeletedMember(): Long = studyLogCommandRepository.deleteAllByDeletedMemberOwner() + + fun hardDeleteStudyLogsOwnedByDeletedDailyGoal(): Long = studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner() + + fun hardDeleteStudyLogsByMember(memberId: Long): Long = studyLogCommandRepository.deleteByMemberId(memberId) +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryService.kt b/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryService.kt new file mode 100644 index 0000000..5d54581 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryService.kt @@ -0,0 +1,62 @@ +package com.ject.studytrip.studylog.application.service + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode +import com.ject.studytrip.studylog.domain.model.StudyLog +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 org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.stereotype.Service + +@Service +class StudyLogQueryService( + private val studyLogRepository: StudyLogRepository, + private val studyLogQueryRepository: StudyLogQueryRepository, +) { + fun getActiveStudyLogCountByMemberId(memberId: Long): Long = studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId) + + fun getStudyLogsSliceByTripId( + tripId: Long, + page: Int, + size: Int, + order: String, + ): Slice = studyLogQueryRepository.findSliceByTripId(tripId, PageRequest.of(page, size), order) + + fun getValidStudyLog(studyLogId: Long): StudyLog { + val studyLog = + studyLogRepository + .findById(studyLogId) + .orElseThrow { CustomException(StudyLogErrorCode.STUDY_LOG_NOT_FOUND) } + + StudyLogPolicy.validateNotDeleted(studyLog) + + return studyLog + } + + fun getValidStudyLogs(studyLogIds: List): List { + val studyLogs = studyLogRepository.findAllByIdIn(studyLogIds) + + StudyLogPolicy.validateExistAll(studyLogs, studyLogIds) + studyLogs.forEach { StudyLogPolicy.validateNotDeleted(it) } + + return studyLogs + } + + fun getStudyLogCountByTripId(tripId: Long): Long = studyLogQueryRepository.countStudyLogsByTripId(tripId) + + fun getStudyLogsSliceByTripReportId( + tripReportId: Long, + page: Int, + size: Int, + ): Slice = + studyLogQueryRepository.findSliceByTripReportIdOrderByCreatedAtDesc( + tripReportId, + PageRequest.of(page, size), + ) + + fun getStudyLogIdsByTripId(tripId: Long): List = studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId) + + fun getStudyLogImageUrlsByMemberId(memberId: Long): List = studyLogQueryRepository.findImageUrlsByMemberId(memberId) +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.kt b/src/main/kotlin/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.kt new file mode 100644 index 0000000..20ef58d --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.kt @@ -0,0 +1,22 @@ +package com.ject.studytrip.studylog.domain.error + +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class StudyLogErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 400 + STUDY_LOG_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 학습 로그입니다."), + + // 404 + STUDY_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "학습 로그를 찾을 수 없습니다."), + ; + + override fun getName(): String = name + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.kt b/src/main/kotlin/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.kt new file mode 100644 index 0000000..cada10c --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.kt @@ -0,0 +1,13 @@ +package com.ject.studytrip.studylog.domain.factory + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.trip.domain.model.DailyGoal + +object StudyLogFactory { + fun create( + member: Member, + dailyGoal: DailyGoal, + content: String, + ): StudyLog = StudyLog.of(member, dailyGoal, content) +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.kt b/src/main/kotlin/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.kt new file mode 100644 index 0000000..c4a11c6 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.kt @@ -0,0 +1,22 @@ +package com.ject.studytrip.studylog.domain.policy + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode +import com.ject.studytrip.studylog.domain.model.StudyLog + +object StudyLogPolicy { + fun validateNotDeleted(studyLog: StudyLog) { + if (studyLog.isDeleted) { + throw CustomException(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED) + } + } + + fun validateExistAll( + foundStudyLogs: List, + requestedIds: List, + ) { + if (foundStudyLogs.size != requestedIds.size) { + throw CustomException(StudyLogErrorCode.STUDY_LOG_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.kt b/src/main/kotlin/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.kt new file mode 100644 index 0000000..22da36b --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.kt @@ -0,0 +1,12 @@ +package com.ject.studytrip.studylog.domain.repository + +import com.ject.studytrip.studylog.domain.model.StudyLog +import java.util.Optional + +interface StudyLogRepository { + fun save(studyLog: StudyLog): StudyLog + + fun findById(studyLogId: Long): Optional + + fun findAllByIdIn(studyLogIds: Collection): List +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.kt b/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.kt new file mode 100644 index 0000000..80ccd82 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogJpaRepository.kt @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.infra.jpa + +import com.ject.studytrip.studylog.domain.model.StudyLog +import org.springframework.data.jpa.repository.JpaRepository + +interface StudyLogJpaRepository : JpaRepository { + fun findAllByIdIn(studyLogIds: Collection): List +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.kt new file mode 100644 index 0000000..3126793 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.kt @@ -0,0 +1,17 @@ +package com.ject.studytrip.studylog.infra.jpa + +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +class StudyLogRepositoryAdapter( + private val studyLogJpaRepository: StudyLogJpaRepository, +) : StudyLogRepository { + override fun save(studyLog: StudyLog): StudyLog = studyLogJpaRepository.save(studyLog) + + override fun findById(studyLogId: Long): Optional = studyLogJpaRepository.findById(studyLogId) + + override fun findAllByIdIn(studyLogIds: Collection): List = studyLogJpaRepository.findAllByIdIn(studyLogIds) +} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java b/src/main/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogController.kt similarity index 52% rename from src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java rename to src/main/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogController.kt index 3753e3b..1e5a5ea 100644 --- a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogController.kt @@ -1,84 +1,79 @@ -package com.ject.studytrip.studylog.presentation.controller; - -import com.ject.studytrip.global.common.response.StandardResponse; -import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; -import com.ject.studytrip.studylog.application.facade.StudyLogFacade; -import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; -import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; -import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; -import com.ject.studytrip.studylog.presentation.dto.response.CreateStudyLogResponse; -import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse; -import com.ject.studytrip.studylog.presentation.dto.response.PresignedStudyLogImageResponse; -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 jakarta.validation.constraints.Pattern; -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.*; +package com.ject.studytrip.studylog.presentation.controller + +import com.ject.studytrip.global.common.response.StandardResponse +import com.ject.studytrip.studylog.application.facade.StudyLogFacade +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest +import com.ject.studytrip.studylog.presentation.dto.response.CreateStudyLogResponse +import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse +import com.ject.studytrip.studylog.presentation.dto.response.PresignedStudyLogImageResponse +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 jakarta.validation.constraints.Pattern +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.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController @Tag(name = "StudyLog", description = "학습 로그 API") @RestController -@RequiredArgsConstructor @Validated -public class StudyLogController { - private final StudyLogFacade studyLogFacade; - +class StudyLogController( + private val studyLogFacade: StudyLogFacade, +) { @Operation(summary = "학습 로그 생성", description = "학습을 완료한 데일리 미션을 선택해 학습 로그를 생성하는 API 입니다.") @PostMapping("/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs") - public ResponseEntity createStudyLog( - @AuthenticationPrincipal String memberId, - @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, - @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") Long dailyGoalId, - @RequestBody @Valid CreateStudyLogRequest request) { - StudyLogInfo result = - studyLogFacade.createStudyLog(Long.valueOf(memberId), tripId, dailyGoalId, request); - - return ResponseEntity.status(HttpStatus.CREATED) - .body( - StandardResponse.success( - HttpStatus.CREATED.value(), CreateStudyLogResponse.of(result))); + fun createStudyLog( + @AuthenticationPrincipal memberId: String, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") tripId: Long, + @PathVariable @NotNull(message = "데일리 목표 ID는 필수 요청 파라미터입니다.") dailyGoalId: Long, + @RequestBody @Valid request: CreateStudyLogRequest, + ): ResponseEntity { + val result = studyLogFacade.createStudyLog(memberId.toLong(), tripId, dailyGoalId, request) + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(StandardResponse.success(HttpStatus.CREATED.value(), CreateStudyLogResponse.of(result))) } @Operation( - summary = "여행의 학습 로그 목록 조회", - description = - "특정 여행의 학습 로그 목록을 조회하는 API 입니다. 슬라이스를 적용하고 정렬 옵션 LATEST(최신순)/OLDEST(과거순)을 적용합니다.") + summary = "여행의 학습 로그 목록 조회", + description = "특정 여행의 학습 로그 목록을 조회하는 API 입니다. 슬라이스를 적용하고 정렬 옵션 LATEST(최신순)/OLDEST(과거순)을 적용합니다.", + ) @GetMapping("/api/trips/{tripId}/study-logs") - public ResponseEntity loadStudyLogsByTrip( - @AuthenticationPrincipal String memberId, - @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, - @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size, - @RequestParam(name = "order", defaultValue = "LATEST") - @Pattern( - regexp = "LATEST|OLDEST", - message = "정렬은 최신순(LATEST) 또는 과거순(OLDEST)만 허용됩니다.") - String order) { - StudyLogSliceInfo result = - studyLogFacade.getStudyLogsByTrip( - Long.valueOf(memberId), tripId, page, size, order); - - return ResponseEntity.status(HttpStatus.OK) - .body( - StandardResponse.success( - HttpStatus.OK.value(), - LoadStudyLogsSliceResponse.of( - result.studyLogDetails(), result.hasNext()))); + fun loadStudyLogsByTrip( + @AuthenticationPrincipal memberId: String, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") tripId: Long, + @RequestParam(name = "page", defaultValue = "0") @Min(0) page: Int, + @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) size: Int, + @RequestParam(name = "order", defaultValue = "LATEST") + @Pattern( + regexp = "LATEST|OLDEST", + message = "정렬은 최신순(LATEST) 또는 과거순(OLDEST)만 허용됩니다.", + ) + order: String, + ): ResponseEntity { + val result = studyLogFacade.getStudyLogsByTrip(memberId.toLong(), tripId, page, size, order) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), LoadStudyLogsSliceResponse.of(result.studyLogDetails, result.hasNext))) } @Operation( - summary = "학습 로그 이미지 업로드용 Presigned URL 발급", - description = - """ + summary = "학습 로그 이미지 업로드용 Presigned URL 발급", + description = """ 학습 로그 이미지를 S3에 업로드하기 위한 Presigned URL을 발급합니다. [흐름] @@ -95,24 +90,28 @@ public ResponseEntity loadStudyLogsByTrip( - 학습로그 이미지 Presigned URL 발급 요청 API는 StudyLogId가 필요하기 때문에 필수로 본 API를 호출하기 전 학습 로그를 먼저 생성해야합니다. - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다. - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. - """) + """, + ) @PostMapping("/api/study-logs/{studyLogId}/images/presigned") - public ResponseEntity presigned( - @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") Long studyLogId, - @RequestBody @Valid PresignStudyLogImageRequest request) { - PresignedStudyLogImageInfo info = studyLogFacade.issuePresignedUrl(studyLogId, request); - return ResponseEntity.ok() - .body( - StandardResponse.success( - HttpStatus.OK.value(), - PresignedStudyLogImageResponse.of( - info.studyLogId(), info.tmpKey(), info.presignedUrl()))); + fun presigned( + @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") studyLogId: Long, + @RequestBody @Valid request: PresignStudyLogImageRequest, + ): ResponseEntity { + val result = studyLogFacade.issuePresignedUrl(studyLogId, request) + + return ResponseEntity + .status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + PresignedStudyLogImageResponse.of(result.studyLogId, result.tmpKey, result.presignedUrl), + ), + ) } @Operation( - summary = "업로드된 학습 로그 이미지 검증/확정", - description = - """ + summary = "업로드된 학습 로그 이미지 검증/확정", + description = """ Presigned URL을 통해 S3에 업로드된 학습 로그 이미지를 서버에서 검증하고 확정(Confirm)합니다. [흐름] @@ -141,12 +140,15 @@ public ResponseEntity presigned( - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. - """) + """, + ) @PostMapping("/api/study-logs/{studyLogId}/images/confirm") - public ResponseEntity confirm( - @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") Long studyLogId, - @RequestBody @Valid ConfirmStudyLogImageRequest request) { - studyLogFacade.confirmImage(studyLogId, request); - return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + fun confirm( + @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") studyLogId: Long, + @RequestBody @Valid request: ConfirmStudyLogImageRequest, + ): ResponseEntity { + studyLogFacade.confirmImage(studyLogId, request) + + return ResponseEntity.status(HttpStatus.OK).body(StandardResponse.success(HttpStatus.OK.value(), null)) } } diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.kt new file mode 100644 index 0000000..98b78a6 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.studylog.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class ConfirmStudyLogImageRequest( + @field:Schema(description = "업로드된 이미지 임시키") + @field:NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") + val tmpKey: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.kt new file mode 100644 index 0000000..4cb2f3d --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/CreateStudyLogRequest.kt @@ -0,0 +1,16 @@ +package com.ject.studytrip.studylog.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotEmpty + +data class CreateStudyLogRequest( + @field:Schema(description = "뽀모도로 총 집중시간(초)") + @field:Min(value = 0, message = "총 집중시간(초)은 음수일 수 없습니다.") + val totalFocusTimeInSeconds: Int, + @field:Schema(description = "선택한 데일리 미션 ID 목록") + @field:NotEmpty(message = "학습로그를 작성할 데일리 미션 목록은 필수 요청 값입니다.") + val selectedDailyMissionIds: List, + @field:Schema(description = "학습 내용") + val content: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.kt new file mode 100644 index 0000000..91694c8 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.studylog.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class PresignStudyLogImageRequest( + @field:Schema(description = "원본 이미지 파일명") + @field:NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") + val originFilename: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.kt new file mode 100644 index 0000000..cc184a8 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/CreateStudyLogResponse.kt @@ -0,0 +1,14 @@ +package com.ject.studytrip.studylog.presentation.dto.response + +import com.ject.studytrip.studylog.application.dto.StudyLogInfo +import io.swagger.v3.oas.annotations.media.Schema + +data class CreateStudyLogResponse( + @field:Schema(description = "생성된 학습 로그 ID") + val studyLogId: Long, +) { + companion object { + @JvmStatic + fun of(info: StudyLogInfo): CreateStudyLogResponse = CreateStudyLogResponse(info.studyLogId) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.kt new file mode 100644 index 0000000..d880644 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.kt @@ -0,0 +1,86 @@ +package com.ject.studytrip.studylog.presentation.dto.response + +import com.ject.studytrip.mission.application.dto.MissionInfo +import com.ject.studytrip.studylog.application.dto.StudyLogDailyMissionInfo +import com.ject.studytrip.studylog.application.dto.StudyLogDetail +import com.ject.studytrip.studylog.application.dto.StudyLogInfo +import io.swagger.v3.oas.annotations.media.Schema + +data class LoadStudyLogsSliceResponse( + @field:Schema(description = "학습 로그 목록") + val studyLogs: List, + @field:Schema(description = "다음 데이터 존재 여부") + val hasNext: Boolean, +) { + companion object { + @JvmStatic + fun of( + studyLogDetails: List, + hasNext: Boolean, + ): LoadStudyLogsSliceResponse = + LoadStudyLogsSliceResponse( + studyLogs = + studyLogDetails.map { detail -> + StudyLogResponse.of( + studyLogInfo = detail.studyLogInfo, + studyLogDailyMissionInfos = detail.studyLogDailyMissionInfos, + ) + }, + hasNext = hasNext, + ) + } + + data class StudyLogResponse( + @field:Schema(description = "학습 로그 ID") + val studyLogId: Long, + @field:Schema(description = "학습 로그에서 선택한 미션 목록") + val dailyMissions: List, + @field:Schema(description = "학습 로그 제목") + val title: String, + @field:Schema(description = "학습 로그 내용") + val content: String, + @field:Schema(description = "학습 로그 이미지 URL") + val imageUrl: String?, + @field:Schema(description = "학습 로그 생성날짜") + val createdAt: String, + ) { + companion object { + fun of( + studyLogInfo: StudyLogInfo, + studyLogDailyMissionInfos: List, + ): StudyLogResponse = + StudyLogResponse( + studyLogId = studyLogInfo.studyLogId, + dailyMissions = + studyLogDailyMissionInfos.map { info -> + StudyLogDailyMissionResponse.of( + studyLogDailyMissionInfo = info, + missionInfo = info.dailyMissionInfo.missionInfo, + ) + }, + title = studyLogInfo.title, + content = studyLogInfo.content, + imageUrl = studyLogInfo.imageUrl, + createdAt = studyLogInfo.createdAt, + ) + } + + data class StudyLogDailyMissionResponse( + @field:Schema(description = "학습 로그 데일리 미션 ID") + val studyLogDailyMissionId: Long, + @field:Schema(description = "미션 이름") + val missionName: String, + ) { + companion object { + fun of( + studyLogDailyMissionInfo: StudyLogDailyMissionInfo, + missionInfo: MissionInfo, + ): StudyLogDailyMissionResponse = + StudyLogDailyMissionResponse( + studyLogDailyMissionId = studyLogDailyMissionInfo.studyLogDailyMissionId, + missionName = missionInfo.missionName, + ) + } + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.kt b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.kt new file mode 100644 index 0000000..5282a3b --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.kt @@ -0,0 +1,26 @@ +package com.ject.studytrip.studylog.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PresignedStudyLogImageResponse( + @field:Schema(description = "학습 로그 ID") + val studyLogId: Long, + @field:Schema(description = "학습 로그 이미지 임시키") + val tmpKey: String, + @field:Schema(description = "학습 로그 이미지 업로드용 Presigned URL") + val presignedUrl: String, +) { + companion object { + @JvmStatic + fun of( + studyLogId: Long, + tmpKey: String, + presignedUrl: String, + ): PresignedStudyLogImageResponse = + PresignedStudyLogImageResponse( + studyLogId = studyLogId, + tmpKey = tmpKey, + presignedUrl = presignedUrl, + ) + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java index 6a641f6..8cb3172 100644 --- a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java +++ b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java @@ -12,4 +12,8 @@ public class TokenFixture { public static TokenProperties createTokenProperties() { return new TokenProperties(TEST_SECRET, ACCESS_EXPIRATION_TIME, REFRESH_EXPIRATION_TIME); } + + public static String authorization(String token) { + return TOKEN_PREFIX + token; + } } diff --git a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java b/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java deleted file mode 100644 index 9691b09..0000000 --- a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.ject.studytrip.pomodoro.application.service; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.fixture.MemberFixture; -import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroCommandRepository; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; -import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; -import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.fixture.DailyGoalFixture; -import com.ject.studytrip.trip.fixture.TripFixture; -import 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("PomodoroCommandService 단위 테스트") -public class PomodoroCommandServiceTest extends BaseUnitTest { - @InjectMocks private PomodoroCommandService pomodoroCommandService; - @Mock private PomodoroRepository pomodoroRepository; - @Mock private PomodoroCommandRepository pomodoroCommandRepository; - - private DailyGoal dailyGoal; - private Pomodoro pomodoro; - - @BeforeEach - void setUp() { - Member member = MemberFixture.createMemberFromKakaoWithId(1L); - Trip trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); - pomodoro = PomodoroFixture.createPomodoroWithId(1L, dailyGoal); - } - - @Nested - @DisplayName("createPomodoro 메서드는") - class CreatePomodoro { - - @Test - @DisplayName("뽀모도로를 생성해 저장하고 반환한다") - void shouldCreateAndReturnPomodoro() { - // given - CreatePomodoroRequest request = new CreatePomodoroRequest(30, 1); - given(pomodoroRepository.save(any())).willReturn(pomodoro); - - // when - Pomodoro result = pomodoroCommandService.createPomodoro(dailyGoal, request); - - // then - assertThat(result).isNotNull(); - assertThat(result.getFocusDurationInSeconds()).isEqualTo(30 * 60); - assertThat(result.getFocusSessionCount()).isEqualTo(1); - } - } - - @Nested - @DisplayName("updateTotalFocusTime 메서드는") - class UpdateTotalFocusTime { - - @Test - @DisplayName("뽀모도로 총 집중시간(분)이 음수일 경우 예외가 발생한다") - void shouldThrowExceptionWhenTotalFocusTimeIsNegative() { - // given - int totalFocusTimeInSeconds = -30; - - // when & then - assertThatThrownBy( - () -> - pomodoroCommandService.updateTotalFocusTime( - pomodoro, totalFocusTimeInSeconds)) - .isInstanceOf(CustomException.class) - .hasMessage(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME.getMessage()); - } - - @Test - @DisplayName("유효한 뽀모도로와 총 학습시간으로 뽀모도로의 총 학습시간을 업데이트한다") - void shouldUpdateTotalFocusTime() { - // given - int totalFocusTimeInSeconds = 120; - - // when - pomodoroCommandService.updateTotalFocusTime(pomodoro, totalFocusTimeInSeconds); - - // then - assertThat(pomodoro.getTotalFocusTimeInSeconds()).isEqualTo(totalFocusTimeInSeconds); - } - } - - @Nested - @DisplayName("deletePomodoro 메서드는") - class DeletePomodoro { - - @Test - @DisplayName("deletedAt 필드를 설정해 삭제 처리한다") - void shouldSoftDeletePomodoro() { - // given - assertThat(pomodoro.getDeletedAt()).isNull(); - - // when - pomodoroCommandService.deletePomodoro(pomodoro); - - // then - assertThat(pomodoro.getDeletedAt()).isNotNull(); - } - } - - @Nested - @DisplayName("hardDeletePomodoros 메서드는") - class HardDeletePomodoros { - - @Test - @DisplayName("삭제된 뽀모도로가 없으면 0을 반환한다.") - void shouldReturnZeroWhenDeletedPomodorosDoNotExist() { - // given - given(pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L); - - // when - long result = pomodoroCommandService.hardDeletePomodoros(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 뽀모도로가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenDeletedPomodorosExist() { - // given - given(pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L); - - // when - long result = pomodoroCommandService.hardDeletePomodoros(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("hardDeletePomodorosOwnedByDeletedDailyGoal 메서드는") - class HardDeletePomodorosOwnedByDeletedDailyGoal { - - @Test - @DisplayName("삭제된 데일리 목표가 소유한 뽀모도로가 없으면 0을 반환한다.") - void shouldReturnZeroWhenPomodorosOwnedByDeletedDailyGoal() { - // given - given(pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(0L); - - // when - long result = pomodoroCommandService.hardDeletePomodorosOwnedByDeletedDailyGoal(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 데일리 목표가 소유한 뽀모도로가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenPomodorosOwnedByDeletedDailyGoal() { - // given - given(pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(5L); - - // when - long result = pomodoroCommandService.hardDeletePomodorosOwnedByDeletedDailyGoal(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("hardDeletePomodorosByMember 메서드는") - class HardDeletePomodorosByMember { - - @Test - @DisplayName("특정 멤버가 소유한 뽀모도로가 없으면 0을 반환한다.") - void shouldReturnZeroWhenPomodorosOwnedByMemberDoNotExist() { - // given - Long memberId = 1L; - given(pomodoroCommandRepository.deleteAllByMemberId(memberId)).willReturn(0L); - - // when - long result = pomodoroCommandService.hardDeletePomodorosByMember(memberId); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("특정 멤버가 소유한 뽀모도로가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenPomodorosOwnedByMemberExist() { - // given - Long memberId = 1L; - given(pomodoroCommandRepository.deleteAllByMemberId(memberId)).willReturn(5L); - - // when - long result = pomodoroCommandService.hardDeletePomodorosByMember(memberId); - - // then - assertThat(result).isEqualTo(5L); - } - } -} 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 deleted file mode 100644 index d4a0a37..0000000 --- a/src/test/java/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.ject.studytrip.pomodoro.application.service; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.fixture.MemberFixture; -import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroQueryRepository; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; -import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.fixture.DailyGoalFixture; -import com.ject.studytrip.trip.fixture.TripFixture; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -@DisplayName("PomodoroQueryService 단위 테스트") -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 = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip); - pomodoro = PomodoroFixture.createPomodoroWithId(1L, dailyGoal); - } - - @Nested - @DisplayName("getValidPomodoroByDailyGoal 메서드는") - class GetValidPomodoroByDailyGoal { - - @Test - @DisplayName("데일리 목표 ID로 뽀모도로를 조회해 반환한다") - void shouldReturnPomodoroByDailyGoalId() { - // given - Long dailyGoalId = dailyGoal.getId(); - given(pomodoroRepository.findByDailyGoalId(dailyGoalId)) - .willReturn(Optional.of(pomodoro)); - - // when - Pomodoro result = pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId); - - // then - assertThat(result).isNotNull(); - assertThat(result.getDailyGoal().getId()).isEqualTo(dailyGoalId); - } - - @Test - @DisplayName("뽀모도로가 존재하지 않으면 예외가 발생한다") - void shouldThrowExceptionIfPomodoroNotFound() { - // given - Long invalidId = -1L; - given(pomodoroRepository.findByDailyGoalId(invalidId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> pomodoroQueryService.getValidPomodoroByDailyGoal(invalidId)) - .isInstanceOf(CustomException.class) - .hasMessage(PomodoroErrorCode.POMODORO_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("삭제된 뽀모도로일 경우 예외가 발생한다") - void shouldThrowExceptionWhenDeletedPomodoro() { - // given - Long dailyGoalId = dailyGoal.getId(); - pomodoro.updateDeletedAt(); - given(pomodoroRepository.findByDailyGoalId(dailyGoalId)) - .willReturn(Optional.of(pomodoro)); - - // when & then - assertThatThrownBy(() -> pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId)) - .isInstanceOf(CustomException.class) - .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/pomodoro/fixture/PomodoroFixture.java b/src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java deleted file mode 100644 index 99ca45b..0000000 --- a/src/test/java/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.ject.studytrip.pomodoro.fixture; - -import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import org.springframework.test.util.ReflectionTestUtils; - -public class PomodoroFixture { - private static final int FOCUS_DURATION_SECONDS = 30 * 60; - private static final int FOCUS_COUNT = 1; - private static final int BREAK_DURATION_SECONDS = 0; - - public static Pomodoro createPomodoro(DailyGoal dailyGoal) { - return PomodoroFactory.create( - dailyGoal, FOCUS_DURATION_SECONDS, FOCUS_COUNT, BREAK_DURATION_SECONDS); - } - - public static Pomodoro createPomodoroWithId(Long id, DailyGoal dailyGoal) { - Pomodoro pomodoro = - PomodoroFactory.create( - dailyGoal, FOCUS_DURATION_SECONDS, FOCUS_COUNT, BREAK_DURATION_SECONDS); - ReflectionTestUtils.setField(pomodoro, "id", id); - - return pomodoro; - } -} diff --git a/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java b/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java deleted file mode 100644 index 3bb4c30..0000000 --- a/src/test/java/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ject.studytrip.pomodoro.helper; - -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository; -import com.ject.studytrip.pomodoro.fixture.PomodoroFixture; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -@Component -public class PomodoroTestHelper { - - @Autowired private PomodoroRepository pomodoroRepository; - - public Pomodoro savePomodoro(DailyGoal dailyGoal) { - return pomodoroRepository.save(PomodoroFixture.createPomodoro(dailyGoal)); - } - - public Pomodoro saveDeletedPomodoro(DailyGoal dailyGoal) { - Pomodoro pomodoro = pomodoroRepository.save(PomodoroFixture.createPomodoro(dailyGoal)); - pomodoro.updateDeletedAt(); - - return pomodoro; - } -} diff --git a/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java b/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java index 8fcfee0..9276b99 100644 --- a/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java +++ b/src/test/java/com/ject/studytrip/stamp/helper/StampTestHelper.java @@ -29,4 +29,8 @@ public Stamp saveCompletedStamp(Trip trip, int order) { return stampRepository.save(stamp); } + + public Stamp getStamp(Long stampId) { + return stampRepository.findById(stampId).orElseThrow(); + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java deleted file mode 100644 index 5bd072e..0000000 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.ject.studytrip.studylog.application.service; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.fixture.MemberFixture; -import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.repository.StudyLogCommandRepository; -import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; -import com.ject.studytrip.studylog.fixture.StudyLogFixture; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.fixture.DailyGoalFixture; -import com.ject.studytrip.trip.fixture.TripFixture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.springframework.test.util.ReflectionTestUtils; - -@DisplayName("StudyLogCommandService 단위 테스트") -class StudyLogCommandServiceTest extends BaseUnitTest { - @InjectMocks private StudyLogCommandService studyLogCommandService; - @Mock private StudyLogRepository studyLogRepository; - @Mock private StudyLogCommandRepository studyLogCommandRepository; - - private Member member; - private Trip courseTrip; - - @BeforeEach - void setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L); - courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE); - } - - @Nested - @DisplayName("createStudyLog 메서드는") - class createStudyLog { - - @Test - @DisplayName("학습 로그를 생성해 저장하고 반환한다") - void shouldReturnCreateStudyLog() { - // given - DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); - String content = "TEST CONTENT"; - - given(studyLogRepository.save(any())) - .willAnswer( - invocation -> { - StudyLog studyLog = invocation.getArgument(0); - ReflectionTestUtils.setField(studyLog, "id", 1L); - return studyLog; - }); - - // when - StudyLog result = studyLogCommandService.createStudyLog(member, dailyGoal, content); - - // then - assertThat(result.getId()).isEqualTo(1L); - assertThat(result.getMember()).isEqualTo(member); - assertThat(result.getDailyGoal()).isEqualTo(dailyGoal); - assertThat(result.getTitle()).isEqualTo(dailyGoal.getTitle()); - assertThat(result.getContent()).isEqualTo(content); - } - } - - @Nested - @DisplayName("hardDeleteStudyLogs 메서드는") - class HardDeleteStudyLogs { - - @Test - @DisplayName("삭제된 학습 로그가 없으면 0을 반환한다.") - void shouldReturnZeroWhenDeletedStudyLogsDoNotExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogs(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 학습 로그가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenDeletedStudyLogsExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogs(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("hardDeleteStudyLogsOwnedByDeletedMember 메서드는") - class HardDeleteStudyLogsOwnedByDeletedMember { - - @Test - @DisplayName("삭제된 멤버가 소유한 학습 로그가 없으면 0을 반환한다.") - void shouldReturnZeroWhenStudyLogsOwnedByDeletedMemberDoNotExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedMemberOwner()).willReturn(0L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedMember(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 멤버가 소유한 학습 로그가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenStudyLogsOwnedByDeletedMemberExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedMemberOwner()).willReturn(5L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedMember(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("hardDeleteStudyLogsOwnedByDeletedDailyGoal 메서드는") - class HardDeleteStudyLogsOwnedByDeletedDailyGoal { - - @Test - @DisplayName("삭제된 데일리 목표가 소유한 학습 로그가 없으면 0을 반환한다.") - void shouldReturnZeroWhenStudyLogsOwnedByDeletedDailyGoalDoNotExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(0L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedDailyGoal(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 데일리 목표가 소유한 학습 로그가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenStudyLogsOwnedByDeletedDailyGoalExist() { - // given - given(studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(5L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedDailyGoal(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("updateImageUrl 메서드는") - class UpdateImageUrl { - private static final String NEW_IMAGE_URL = - "https://cdn.example.com/study-logs/1/image.jpg"; - - @Test - @DisplayName("삭제된 학습 로그의 이미지 URL을 수정하면 예외가 발생한다") - void shouldThrowExceptionWhenStudyLogIsDeleted() { - // given - DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); - StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); - studyLog.updateDeletedAt(); - - // when & then - assertThatThrownBy(() -> studyLogCommandService.updateImageUrl(studyLog, NEW_IMAGE_URL)) - .isInstanceOf(CustomException.class) - .hasMessage(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.getMessage()); - } - - @Test - @DisplayName("유효한 학습 로그의 이미지 URL을 수정한다") - void shouldUpdateImageUrlWhenStudyLogIsValid() { - // given - DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); - StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); - String oldImageUrl = studyLog.getImageUrl(); - - // when - studyLogCommandService.updateImageUrl(studyLog, NEW_IMAGE_URL); - - // then - assertThat(studyLog.getImageUrl()).isEqualTo(NEW_IMAGE_URL); - assertThat(studyLog.getImageUrl()).isNotEqualTo(oldImageUrl); - } - } - - @Nested - @DisplayName("hardDeleteStudyLogsByMember 메서드는") - class HardDeleteStudyLogsByMember { - - @Test - @DisplayName("특정 멤버가 소유한 학습 로그가 없으면 0을 반환한다.") - void shouldReturnZeroWhenStudyLogsOwnedByMemberDoNotExist() { - // given - Long memberId = 1L; - given(studyLogCommandRepository.deleteByMemberId(memberId)).willReturn(0L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("특정 멤버가 소유한 학습 로그가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenStudyLogsOwnedByMemberExist() { - // given - Long memberId = 1L; - given(studyLogCommandRepository.deleteByMemberId(memberId)).willReturn(5L); - - // when - long result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId); - - // then - assertThat(result).isEqualTo(5L); - } - } -} diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java index 780f675..05322c5 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.java @@ -60,7 +60,7 @@ class createStudyLogDailyMissions { @DisplayName("학습 로그와 데일리 미션 목록으로 학습 로그 데일리 미션을 생성하여 저장하고 반환한다") void shouldReturnCreateStudyLogDailyMissions() { // given - StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + StudyLog studyLog = new StudyLogFixture(member, dailyGoal).createWithId(1L); DailyMission dailyMission1 = DailyMissionFixture.createDailyMissionWithId(1L, mission1, dailyGoal); DailyMission dailyMission2 = 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 deleted file mode 100644 index d978064..0000000 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java +++ /dev/null @@ -1,360 +0,0 @@ -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.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.studylog.domain.error.StudyLogErrorCode; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; -import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; -import com.ject.studytrip.studylog.fixture.StudyLogFixture; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.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; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; - -@DisplayName("StudyLogQueryService 단위 테스트") -class StudyLogQueryServiceTest extends BaseUnitTest { - @InjectMocks private StudyLogQueryService studyLogQueryService; - @Mock private StudyLogRepository studyLogRepository; - @Mock private StudyLogQueryRepository studyLogQueryRepository; - - 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 - @DisplayName("getActiveStudyLogCountByMemberId 메서드는") - class GetActiveStudyLogCountByMemberId { - - @Test - @DisplayName("해당 멤버의 학습 기록이 존재하지 않으면 0을 반환한다.") - void shouldReturnZeroWhenStudyLogDoesNotExistForMember() { - // given - Long memberId = member.getId(); - given(studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId)).willReturn(0L); - - // when - long result = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId); - - // then - assertThat(result).isZero(); - } - - @Test - @DisplayName("해당 멤버의 학습 기록이 존재하면 그 개수를 반환한다.") - void shouldReturnCountWhenStudyLogExistsForMember() { - // given - Long memberId = member.getId(); - given(studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId)).willReturn(3L); - - // when - long result = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId); - - // then - assertThat(result).isEqualTo(3L); - } - } - - @Nested - @DisplayName("getStudyLogsSliceByTripId 메서드는") - class getStudyLogsSliceByTripId { - - @Test - @DisplayName("특정 여행의 학습 로그 목록을 페이징 처리와 최신순으로 정렬하고 반환한다") - void shouldReturnStudyLogsByTripIdWithLatestOrder() { - // given - Long tripId = courseTrip.getId(); - List studyLogs = List.of(studyLog1, studyLog2); - - int page = 0; - int size = 5; - String order = "LATEST"; - Pageable pageable = PageRequest.of(page, size); - - Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); - - given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)) - .willReturn(mockSlice); - - // when - Slice result = - studyLogQueryService.getStudyLogsSliceByTripId(tripId, page, size, order); - - // then - assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); - assertThat(result.getContent().get(0)).isEqualTo(studyLog1); - assertThat(result.getContent().get(1)).isEqualTo(studyLog2); - } - - @Test - @DisplayName("특정 여행의 학습 로그 목록을 페이징 처리와 과거순으로 정렬하고 반환한다") - void shouldReturnStudyLogsByTripIdWithOldestOrder() { - // given - Long tripId = courseTrip.getId(); - List studyLogs = List.of(studyLog2, studyLog1); // 과거순이므로 순서 반대 - - int page = 0; - int size = 5; - String order = "OLDEST"; - Pageable pageable = PageRequest.of(page, size); - - Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); - - given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)) - .willReturn(mockSlice); - - // when - Slice result = - studyLogQueryService.getStudyLogsSliceByTripId(tripId, page, size, order); - - // then - assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); - assertThat(result.getContent().get(0)).isEqualTo(studyLog2); - assertThat(result.getContent().get(1)).isEqualTo(studyLog1); - } - } - - @Nested - @DisplayName("getValidStudyLog 메서드는") - class GetValidStudyLog { - - @Test - @DisplayName("존재하지 않는 학습 로그 ID로 조회하면 예외가 발생한다") - void shouldThrowExceptionWhenStudyLogNotFound() { - // given - Long invalidId = -1L; - given(studyLogRepository.findById(invalidId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> studyLogQueryService.getValidStudyLog(invalidId)) - .isInstanceOf(CustomException.class) - .hasMessage(StudyLogErrorCode.STUDY_LOG_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("삭제된 학습 로그를 조회하면 예외가 발생한다") - void shouldThrowExceptionWhenStudyLogIsDeleted() { - // given - StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); - studyLog.updateDeletedAt(); - - given(studyLogRepository.findById(1L)).willReturn(Optional.of(studyLog)); - - // when & then - assertThatThrownBy(() -> studyLogQueryService.getValidStudyLog(1L)) - .isInstanceOf(CustomException.class) - .hasMessage(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.getMessage()); - } - - @Test - @DisplayName("유효한 학습 로그 ID로 조회하면 학습 로그를 반환한다") - void shouldReturnStudyLogWhenIdIsValid() { - // given - StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); - - given(studyLogRepository.findById(1L)).willReturn(Optional.of(studyLog)); - - // when - StudyLog result = studyLogQueryService.getValidStudyLog(1L); - - // then - assertThat(result).isEqualTo(studyLog); - assertThat(result.getDeletedAt()).isNull(); - } - } - - @Nested - @DisplayName("getStudyLogCountByTripId 메서드는") - class GetStudyLogCountByTripId { - - @Test - @DisplayName("해당 여행의 학습 로그가 존재하지 않으면 0을 반환한다.") - void shouldReturnZeroWhenStudyLogDoesNotExistForTrip() { - // given - Long tripId = courseTrip.getId(); - studyLog1.updateDeletedAt(); - studyLog2.updateDeletedAt(); - given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(0L); - - // when - long result = studyLogQueryService.getStudyLogCountByTripId(tripId); - - // then - assertThat(result).isZero(); - } - - @Test - @DisplayName("해당 여행의 데일리 목표가 존재하지 않으면 0을 반환한다.") - void shouldReturnZeroWhenDailyGoalDoesNotExistForTrip() { - // given - Long tripId = courseTrip.getId(); - dailyGoal.updateDeletedAt(); - given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(0L); - - // when - long result = studyLogQueryService.getStudyLogCountByTripId(tripId); - - // then - assertThat(result).isZero(); - } - - @Test - @DisplayName("해당 여행의 학습 로그가 존재하면 그 개수를 반환한다.") - void shouldReturnCountWhenStudyLogExistsForTrip() { - // given - Long tripId = courseTrip.getId(); - given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(2L); - - // when - long result = studyLogQueryService.getStudyLogCountByTripId(tripId); - - // then - assertThat(result).isEqualTo(2L); - } - } - - @Nested - @DisplayName("getStudyLogsSliceByTripReportId 메서드는") - 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.findSliceByTripReportIdOrderByCreatedAtDesc( - tripReport.getId(), pageable)) - .willReturn(mockSlice); - - // when - Slice result = - studyLogQueryService.getStudyLogsSliceByTripReportId( - 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); - } - } - - @Nested - @DisplayName("getStudyLogIdsByTripId 메서드는") - class GetStudyLogIdsByTripId { - - @Test - @DisplayName("학습 로그가 존재하지 않으면 빈 리스트를 반환한다.") - void shouldReturnEmptyListWhenStudyLogDoesNotExist() { - // given - Long tripId = courseTrip.getId(); - studyLog1.updateDeletedAt(); - studyLog2.updateDeletedAt(); - given(studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId)) - .willReturn(List.of()); - - // when - List result = studyLogQueryService.getStudyLogIdsByTripId(tripId); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("학습 로그가 하나라도 존재하면 학습 로그 ID 목록을 반환한다.") - void shouldReturnStudyLogIdsWhenStudyLogExists() { - // given - Long tripId = courseTrip.getId(); - Long studyLogId1 = studyLog1.getId(); - Long studyLogId2 = studyLog2.getId(); - given(studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId)) - .willReturn(List.of(studyLogId1, studyLogId2)); - - // when - List result = studyLogQueryService.getStudyLogIdsByTripId(tripId); - - // then - assertThat(result).hasSize(2); - assertThat(result.get(0)).isEqualTo(studyLogId1); - assertThat(result.get(1)).isEqualTo(studyLogId2); - } - } - - @Nested - @DisplayName("getStudyLogImageUrlsByMemberId 메서드는") - class GetStudyLogImageUrlsByMemberId { - - @Test - @DisplayName("이미지가 없으면 빈 리스트를 반환한다") - void shouldReturnEmptyListWhenNoImages() { - // given - Long memberId = member.getId(); - given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(List.of()); - - // when - List result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("이미지가 존재하면 URL 리스트를 반환한다") - void shouldReturnImageUrlsWhenExist() { - // given - Long memberId = member.getId(); - List imageUrls = - List.of( - "https://cdn.example.com/studylogs/1.jpg", - "https://cdn.example.com/studylogs/2.jpg"); - given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(imageUrls); - - // when - List result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId); - - // then - assertThat(result).hasSize(2); - assertThat(result).isEqualTo(imageUrls); - } - } -} diff --git a/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java b/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java deleted file mode 100644 index ae78ee4..0000000 --- a/src/test/java/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ject.studytrip.studylog.fixture; - -import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; -import java.util.List; - -public class CreateStudyLogRequestFixture { - private int totalFocusTimeInMinutes = 75; - private List selectedDailyMissionIds = List.of(1L); - private String content = "TEST 학습 로그 내용"; - - public CreateStudyLogRequestFixture withTotalFocusTimeInMinutes(int totalFocusTimeInMinutes) { - this.totalFocusTimeInMinutes = totalFocusTimeInMinutes; - return this; - } - - public CreateStudyLogRequestFixture withSelectedDailyMissionIds(List ids) { - this.selectedDailyMissionIds = ids; - return this; - } - - public CreateStudyLogRequestFixture withContent(String content) { - this.content = content; - return this; - } - - public CreateStudyLogRequest build() { - return new CreateStudyLogRequest(totalFocusTimeInMinutes, selectedDailyMissionIds, content); - } -} diff --git a/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java b/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java deleted file mode 100644 index 1336467..0000000 --- a/src/test/java/com/ject/studytrip/studylog/fixture/StudyLogFixture.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ject.studytrip.studylog.fixture; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.studylog.domain.factory.StudyLogFactory; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import org.springframework.test.util.ReflectionTestUtils; - -public class StudyLogFixture { - private static final String STUDY_LOG_CONTENT = "TEST 학습 로그 내용"; - - public static StudyLog createStudyLog(Member member, DailyGoal dailyGoal) { - return StudyLogFactory.create(member, dailyGoal, STUDY_LOG_CONTENT); - } - - public static StudyLog createStudyLogWithId(Long id, Member member, DailyGoal dailyGoal) { - StudyLog studyLog = createStudyLog(member, dailyGoal); - ReflectionTestUtils.setField(studyLog, "id", id); - - return studyLog; - } -} diff --git a/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java b/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java deleted file mode 100644 index 856a092..0000000 --- a/src/test/java/com/ject/studytrip/studylog/helper/StudyLogTestHelper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ject.studytrip.studylog.helper; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; -import com.ject.studytrip.studylog.fixture.StudyLogFixture; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -@Component -public class StudyLogTestHelper { - @Autowired private StudyLogRepository studyLogRepository; - - public StudyLog saveStudyLog(Member member, DailyGoal dailyGoal) { - StudyLog studyLog = StudyLogFixture.createStudyLog(member, dailyGoal); - return studyLogRepository.save(studyLog); - } -} diff --git a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java deleted file mode 100644 index ad0b935..0000000 --- a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java +++ /dev/null @@ -1,965 +0,0 @@ -package com.ject.studytrip.studylog.presentation.controller; - -import static org.assertj.core.api.Assertions.assertThat; -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.domain.model.MemberRole; -import com.ject.studytrip.member.helper.MemberTestHelper; -import com.ject.studytrip.mission.domain.error.DailyMissionErrorCode; -import com.ject.studytrip.mission.domain.error.MissionErrorCode; -import com.ject.studytrip.mission.domain.model.DailyMission; -import com.ject.studytrip.mission.domain.model.Mission; -import com.ject.studytrip.mission.helper.DailyMissionTestHelper; -import com.ject.studytrip.mission.helper.MissionTestHelper; -import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode; -import com.ject.studytrip.pomodoro.domain.model.Pomodoro; -import com.ject.studytrip.pomodoro.helper.PomodoroTestHelper; -import com.ject.studytrip.stamp.domain.model.Stamp; -import com.ject.studytrip.stamp.helper.StampTestHelper; -import com.ject.studytrip.studylog.domain.model.StudyLog; -import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; -import com.ject.studytrip.studylog.fixture.CreateStudyLogRequestFixture; -import com.ject.studytrip.studylog.helper.StudyLogDailyMissionTestHelper; -import com.ject.studytrip.studylog.helper.StudyLogTestHelper; -import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; -import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; -import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; -import com.ject.studytrip.trip.domain.error.DailyGoalErrorCode; -import com.ject.studytrip.trip.domain.error.TripErrorCode; -import com.ject.studytrip.trip.domain.model.DailyGoal; -import com.ject.studytrip.trip.domain.model.Trip; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.helper.DailyGoalTestHelper; -import com.ject.studytrip.trip.helper.TripTestHelper; -import java.util.List; -import org.apache.http.HttpHeaders; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.ResultActions; - -@DisplayName("StudyLogController 통합 테스트") -public class StudyLogControllerIntegrationTest extends BaseIntegrationTest { - @Autowired private MemberTestHelper memberTestHelper; - @Autowired private TokenTestHelper tokenTestHelper; - @Autowired private TripTestHelper tripTestHelper; - @Autowired private StampTestHelper stampTestHelper; - @Autowired private MissionTestHelper missionTestHelper; - @Autowired private DailyGoalTestHelper dailyGoalTestHelper; - @Autowired private DailyMissionTestHelper dailyMissionTestHelper; - @Autowired private StudyLogTestHelper studyLogTestHelper; - @Autowired private StudyLogDailyMissionTestHelper studyLogDailyMissionTestHelper; - @Autowired private PomodoroTestHelper pomodoroTestHelper; - - @MockitoBean S3ImageStorageProvider s3ImageStorageProvider; - - private Member member; - private String token; - private Trip courseTrip; - private Stamp stamp; - private Mission mission; - private DailyGoal dailyGoal; - private DailyMission dailyMission; - private Pomodoro pomodoro; - - @BeforeEach - void setUp() { - member = memberTestHelper.saveMember(); - token = - tokenTestHelper.createAccessToken( - member.getId().toString(), MemberRole.ROLE_USER.name()); - courseTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); - stamp = stampTestHelper.saveStamp(courseTrip, 1); - mission = missionTestHelper.saveMission(stamp); - dailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); - dailyMission = dailyMissionTestHelper.saveDailyMission(mission, dailyGoal); - pomodoro = pomodoroTestHelper.savePomodoro(dailyGoal); - } - - @Nested - @DisplayName("학습 로그 생성 API") - class CreateStudyLog { - private final CreateStudyLogRequestFixture fixture = new CreateStudyLogRequestFixture(); - - private ResultActions getResultActions( - String token, Object tripId, Object dailyGoalId, CreateStudyLogRequest request) - throws Exception { - return mockMvc.perform( - post( - "/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs", - tripId, - dailyGoalId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("유효한 요청으로 학습 로그를 생성한다") - void shouldCreateStudyLog() throws Exception { - // given - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId())).build(); - int initialCompletedMissions = stamp.getCompletedMissions(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.studyLogId").isNumber()); - - // 스탬프의 완료된 미션 수가 증가했는지 확인 - assertThat(stamp.getCompletedMissions()).isEqualTo(initialCompletedMissions + 1); - } - - @Test - @DisplayName("여러 미션을 선택한 경우 스탬프의 완료된 미션 수가 올바르게 증가한다") - void shouldUpdateCompletedMissionsWhenMultipleMissionsSelected() throws Exception { - // given - Mission mission2 = missionTestHelper.saveMission(stamp); - DailyMission dailyMission2 = - dailyMissionTestHelper.saveDailyMission(mission2, dailyGoal); - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds( - List.of(dailyMission.getId(), dailyMission2.getId())) - .build(); - int initialCompletedMissions = stamp.getCompletedMissions(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.studyLogId").isNumber()); - - // 스탬프의 완료된 미션 수가 2개 증가했는지 확인 - assertThat(stamp.getCompletedMissions()).isEqualTo(initialCompletedMissions + 2); - } - - @Test - @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { - // given - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions("", courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); - } - - @Test - @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { - // given - String tripId = "abc"; - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, tripId, dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getStatus() - .value())); - } - - @Test - @DisplayName("PathVariable 데일리 목표 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenDailyGoalIdTypeMismatch() throws Exception { - // given - String dailyGoalId = "abc"; - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoalId, request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getStatus() - .value())); - } - - @Test - @DisplayName("학습 로그를 생성하는데 필요한 필수 요청 값이 누락되거나 유효하지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenInvalidRequiredFields() throws Exception { - // given - CreateStudyLogRequest request = - fixture.withTotalFocusTimeInMinutes(-30) - .withSelectedDailyMissionIds(List.of()) - .build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_NOT_VALID - .getStatus() - .value())); - } - - @Test - @DisplayName("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") - void shouldReturnNotFoundWhenInvalidTripId() throws Exception { - // given - Long tripId = 10000L; - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, tripId, dailyGoal.getId(), request); - - // when & then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); - } - - @Test - @DisplayName("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") - void shouldReturnForbiddenWhenNotTripOwner() throws Exception { - // given - Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); - Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, newTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); - } - - @Test - @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { - // given - Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, deleted.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); - } - - @Test - @DisplayName("유효하지 않은 데일리 목표 ID 라면 404 NotFound를 반환한다") - void shouldReturnNotFoundWhenInvalidDailyGoalId() throws Exception { - // given - Long dailyGoalId = 10000L; - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoalId, request); - - // when & then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND - .getStatus() - .value())); - } - - @Test - @DisplayName("조회된 데일리 목표가 요청한 여행에 속하지 않을 경우 403 Forbidden을 반환한다") - void shouldReturnForbiddenWhenDailyGoalNotBelongToTrip() throws Exception { - // given - Trip newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE); - DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip); - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP - .getStatus() - .value())); - } - - @Test - @DisplayName("삭제된 데일리 목표일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenAlreadyDailyGoal() throws Exception { - // given - DailyGoal deleted = dailyGoalTestHelper.saveDeletedDailyGoal(courseTrip); - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), deleted.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED - .getStatus() - .value())); - } - - @Test - @DisplayName("선택한 데일리 미션 목록과 조회된 데일리 미션 목록이 일치하지 않으면 404 NotFound를 반환한다") - void shouldReturnNotFoundWhenAlreadyDailyMissions() throws Exception { - // given - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId(), 1000L)) - .build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND - .getStatus() - .value())); - } - - @Test - @DisplayName("조회된 데일리 미션이 요청한 데일리 목표에 속하지 않을 경우 403 Forbidden을 반환한다") - void shouldReturnForbiddenWhenDailyMissionNotBelongToDailyGoal() throws Exception { - // given - DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(dailyMission.getId())).build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyMissionErrorCode - .DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL - .getStatus() - .value())); - } - - @Test - @DisplayName("삭제된 데일리 미션일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenAlreadyDailyMission() throws Exception { - // given - DailyMission deleted = - dailyMissionTestHelper.saveDeletedDailyMission(mission, dailyGoal); - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(deleted.getId())).build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED - .getStatus() - .value())); - } - - @Test - @DisplayName("요청한 DailyGoalId의 뽀모도로가 존재하지 않을 경우 404 NotFound를 반환한다") - void shouldReturnNotFoundWhenPomodoroNotFound() throws Exception { - // given - DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); - CreateStudyLogRequest request = fixture.build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - PomodoroErrorCode.POMODORO_NOT_FOUND - .getStatus() - .value())); - } - - @Test - @DisplayName("요청한 DailyGoalId의 뽀모도로가 삭제된 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenPomodoroIsDeleted() throws Exception { - // given - DailyGoal newDailyGoal = dailyGoalTestHelper.saveDailyGoal(courseTrip); - DailyMission newDailyMission = - dailyMissionTestHelper.saveDailyMission(mission, newDailyGoal); - pomodoroTestHelper.saveDeletedPomodoro(newDailyGoal); - - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), newDailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - PomodoroErrorCode.POMODORO_ALREADY_DELETED - .getStatus() - .value())); - } - - @Test - @DisplayName("선택된 미션들을 완료 처리할 때, 이미 삭제된 미션일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenSelectedMissionIsAlreadyDeleted() throws Exception { - // given - Mission deletedMission = missionTestHelper.saveDeletedMission(stamp); - DailyMission newDailyMission = - dailyMissionTestHelper.saveDailyMission(deletedMission, dailyGoal); - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - MissionErrorCode.MISSION_ALREADY_DELETED - .getStatus() - .value())); - } - - @Test - @DisplayName("선택된 미션들을 완료 처리할 때, 이미 완료된 미션일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenSelectedMissionIsAlreadyCompleted() throws Exception { - // given - Mission deletedMission = missionTestHelper.saveCompletedMission(stamp); - DailyMission newDailyMission = - dailyMissionTestHelper.saveDailyMission(deletedMission, dailyGoal); - CreateStudyLogRequest request = - fixture.withSelectedDailyMissionIds(List.of(newDailyMission.getId())).build(); - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), dailyGoal.getId(), request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - MissionErrorCode.MISSION_ALREADY_COMPLETED - .getStatus() - .value())); - } - } - - @Nested - @DisplayName("학습 로그 목록 조회 API") - class ListStudyLogs { - private static final String DEFAULT_PAGE = "0"; - private static final String DEFAULT_PAGE_SIZE = "5"; - private static final String DEFAULT_ORDER = "LATEST"; - - private ResultActions getResultActions( - String token, Object tripId, String page, String size, String order) - throws Exception { - return mockMvc.perform( - get("/api/trips/{tripId}/study-logs", tripId) - .param("page", page) - .param("size", size) - .param("order", order) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token)); - } - - @Test - @DisplayName("특정 여행의 학습 로그 목록을 조회하고 슬라이스 처리해 반환한다") - void shouldLoadStudyLogsByTripWithSlicePaging() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - List studyLogDailyMissions = - studyLogDailyMissionTestHelper.saveStudyLogDailyMissions( - studyLog, dailyMission); - - // when - ResultActions resultActions = - getResultActions( - token, - courseTrip.getId(), - DEFAULT_PAGE, - DEFAULT_PAGE_SIZE, - DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.studyLogs").isNotEmpty()) - .andExpect(jsonPath("$.data.hasNext").value(false)) - .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.getId())) - .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty()) - .andExpect( - jsonPath("$.data.studyLogs[0].dailyMissions") - .value(Matchers.hasSize(studyLogDailyMissions.size()))); - } - - @Test - @DisplayName("order 파라미터를 OLDEST로 지정하면 과거순으로 정렬된 학습 로그 목록을 반환한다") - void shouldLoadStudyLogsByTripWithOldestOrder() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - List studyLogDailyMissions = - studyLogDailyMissionTestHelper.saveStudyLogDailyMissions( - studyLog, dailyMission); - - // when - ResultActions resultActions = - getResultActions( - token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, "OLDEST"); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.studyLogs").isNotEmpty()) - .andExpect(jsonPath("$.data.hasNext").value(false)) - .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.getId())) - .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty()) - .andExpect( - jsonPath("$.data.studyLogs[0].dailyMissions") - .value(Matchers.hasSize(studyLogDailyMissions.size()))); - } - - @Test - @DisplayName("order 파라미터가 유효하지 않은 값이면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenOrderIsInvalid() throws Exception { - // when - ResultActions resultActions = - getResultActions( - token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, "INVALID"); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_NOT_VALID - .getStatus() - .value())); - } - - @Test - @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { - // when - ResultActions resultActions = - getResultActions( - "", courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); - } - - @Test - @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { - // given - String tripId = "abc"; - - // when - ResultActions resultActions = - getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getStatus() - .value())); - } - - @Test - @DisplayName("Request Param 페이징 데이터 타입이 올바르지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenWhenPagingParameterTypeMismatch() throws Exception { - // Given - String page = "test"; - String size = "test"; - - // when - ResultActions resultActions = - getResultActions(token, courseTrip, page, size, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getStatus() - .value())); - } - - @Test - @DisplayName("Request Param 페이징 데이터가 유효하지 않으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenWhenPagingParameterIsInvalid() throws Exception { - // Given - String page = "-1"; - String size = "100"; - - // when - ResultActions resultActions = - getResultActions(token, courseTrip.getId(), page, size, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_NOT_VALID - .getStatus() - .value())); - } - - @Test - @DisplayName("유효하지 않은 여행 ID 라면 404 NotFound를 반환한다") - void shouldReturnNotFoundWhenInvalidTripId() throws Exception { - // given - Long tripId = 10000L; - - // when - ResultActions resultActions = - getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); - - // when & then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); - } - - @Test - @DisplayName("요청한 여행의 소유자가 아닐 경우 403 Forbidden을 반환한다") - void shouldReturnForbiddenWhenNotTripOwner() throws Exception { - // given - Member newMember = memberTestHelper.saveMember("test@gmail.com", "TEST"); - Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); - - // when - ResultActions resultActions = - getResultActions( - token, newTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); - } - - @Test - @DisplayName("삭제된 여행일 경우 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { - // given - Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); - - // when - ResultActions resultActions = - getResultActions( - token, deleted.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); - } - } - - @Nested - @DisplayName("학습 로그 이미지 Presigned URL 발급 API") - class IssuePresignedUrl { - private static final String PRESIGNED_URL = "/api/study-logs/%d/images/presigned"; - - private ResultActions getResultActions( - String token, Long studyLogId, PresignStudyLogImageRequest request) - throws Exception { - return mockMvc.perform( - post(String.format(PRESIGNED_URL, studyLogId)) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("test.jpg"); - - // when - ResultActions resultActions = getResultActions("", studyLog.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("유효한 파일명으로 Presigned URL을 발급한다") - void shouldIssuePresignedUrlWhenFilenameIsValid() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("studylog.jpg"); - given(s3ImageStorageProvider.issuePresignedUrl(anyString())) - .willReturn("https://mocked-presigned-url.com"); - - // when - ResultActions resultActions = getResultActions(token, studyLog.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/study-logs/"))) - .andExpect( - jsonPath("$.data.tmpKey") - .value(Matchers.containsString(studyLog.getId().toString()))); - - // S3Provider 호출 검증 - verify(s3ImageStorageProvider).issuePresignedUrl(anyString()); - } - - @Test - @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenFilenameIsEmpty() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - PresignStudyLogImageRequest request = new PresignStudyLogImageRequest(""); - - // when - ResultActions resultActions = getResultActions(token, studyLog.getId(), request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenExtensionIsInvalid() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("image.pdf"); - - // when - ResultActions resultActions = getResultActions(token, studyLog.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())); - } - } - - @Nested - @DisplayName("학습 로그 이미지 확정 API") - class ConfirmImage { - private static final String CONFIRM_URL = "/api/study-logs/%d/images/confirm"; - - private ResultActions getResultActions( - String token, Long studyLogId, ConfirmStudyLogImageRequest request) - throws Exception { - return mockMvc.perform( - post(String.format(CONFIRM_URL, studyLogId)) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // given - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - ConfirmStudyLogImageRequest request = - new ConfirmStudyLogImageRequest("tmp/study-logs/1/test.jpg"); - - // when - ResultActions resultActions = getResultActions("", studyLog.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 - StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); - ConfirmStudyLogImageRequest request = new ConfirmStudyLogImageRequest(""); - - // when - ResultActions resultActions = getResultActions(token, studyLog.getId(), request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - } -} 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 index e36d90c..f4486eb 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.java @@ -40,8 +40,8 @@ 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); + StudyLog studyLog1 = new StudyLogFixture(member, dailyGoal).createWithId(1L); + StudyLog studyLog2 = new StudyLogFixture(member, dailyGoal).createWithId(2L); tripReport = TripReportFixture.createTripReportWithId(1L, member); studyLogs = List.of(studyLog1, studyLog2); } diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt new file mode 100644 index 0000000..a8f9878 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt @@ -0,0 +1,247 @@ +package com.ject.studytrip.pomodoro.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.fixture.MemberFixture +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.pomodoro.domain.repository.PomodoroCommandRepository +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository +import com.ject.studytrip.pomodoro.fixture.CreatePomodoroRequestFixture +import com.ject.studytrip.pomodoro.fixture.PomodoroFixture +import com.ject.studytrip.trip.domain.model.DailyGoal +import com.ject.studytrip.trip.domain.model.TripCategory +import com.ject.studytrip.trip.fixture.DailyGoalFixture +import com.ject.studytrip.trip.fixture.TripFixture +import org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.kotlin.any + +@DisplayName("PomodoroCommandService 단위 테스트") +class PomodoroCommandServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var pomodoroCommandService: PomodoroCommandService + + @Mock + private lateinit var pomodoroRepository: PomodoroRepository + + @Mock + private lateinit var pomodoroCommandRepository: PomodoroCommandRepository + + private lateinit var member: Member + private lateinit var dailyGoal: DailyGoal + private lateinit var pomodoro: Pomodoro + + @BeforeEach + fun setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L) + val trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE) + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip) + pomodoro = PomodoroFixture(dailyGoal).createWithId(1L) + } + + @Nested + @DisplayName("createPomodoro 메서드는") + inner class CreatePomodoro { + @Test + @DisplayName("유효한 요청이 들어오면 뽀모도로를 생성하고 반환한다.") + fun shouldReturnPomodoroWhenRequestIsValid() { + // given + val request = + CreatePomodoroRequestFixture() + .apply { + focusDurationInMinute = 30 + focusSessionCount = 1 + }.build() + given(pomodoroRepository.save(any())).willReturn(pomodoro) + + // when + val result = pomodoroCommandService.createPomodoro(dailyGoal, request) + + // then + assertThat(result).isEqualTo(pomodoro) + assertThat(result.focusDurationInSeconds).isEqualTo(30 * 60) + assertThat(result.focusSessionCount).isEqualTo(1) + } + } + + @Nested + @DisplayName("updateTotalFocusTime 메서드는") + inner class UpdateTotalFocusTime { + @Test + @DisplayName("뽀모도로가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenPomodoroAlreadyDeleted() { + // given + val totalFocusTimeInSeconds = 120 + pomodoro.updateDeletedAt() + + // when + val exception = + assertThrows { + pomodoroCommandService.updateTotalFocusTime(pomodoro, totalFocusTimeInSeconds) + } + + // then + assertThat(exception.message).isEqualTo(PomodoroErrorCode.POMODORO_ALREADY_DELETED.message) + } + + @Test + @DisplayName("뽀모도로 총 집중시간(분)이 음수라면 예외가 발생한다.") + fun shouldThrowExceptionWhenTotalFocusTimeIsNegative() { + // given + val totalFocusTimeInSeconds = -30 + + // when + val exception = + assertThrows { + pomodoroCommandService.updateTotalFocusTime(pomodoro, totalFocusTimeInSeconds) + } + + // then + assertThat(exception.message).isEqualTo(PomodoroErrorCode.POMODORO_NEGATIVE_FOCUS_TIME.message) + } + + @Test + @DisplayName("뽀모도로와 총 집중시간(분)이 유효하면 총 집중시간을 업데이트한다.") + fun shouldUpdateTotalFocusTimeWhenPomodoroAndTotalFocusTimeIsValid() { + // given + val totalFocusTimeInSeconds = 120 + + // when + pomodoroCommandService.updateTotalFocusTime(pomodoro, totalFocusTimeInSeconds) + + // then + assertThat(pomodoro.totalFocusTimeInSeconds).isEqualTo(totalFocusTimeInSeconds) + } + } + + @Nested + @DisplayName("deletePomodoro 메서드는") + inner class DeletePomodoro { + @Test + @DisplayName("뽀모도로가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenPomodoroAlreadyDeleted() { + // given + pomodoro.updateDeletedAt() + + // when + val exception = + assertThrows { + pomodoroCommandService.deletePomodoro(pomodoro) + } + + // then + assertThat(exception.message).isEqualTo(PomodoroErrorCode.POMODORO_ALREADY_DELETED.message) + } + + @Test + @DisplayName("뽀모도로가 삭제될 때 deletedAt 필드를 현재 시간으로 업데이트한다. (소프트 삭제)") + fun shouldUpdateDeletedAtWhenPomodoroIsDeleted() { + // when + pomodoroCommandService.deletePomodoro(pomodoro) + + // then + assertThat(pomodoro.deletedAt).isNotNull + } + } + + @Nested + @DisplayName("hardDeletePomodoros 메서드는") + inner class HardDeletePomodoros { + @Test + @DisplayName("삭제된 뽀모도로가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenDeletedPomodorosDoNotExist() { + // given + given(pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L) + + // when + val result = pomodoroCommandService.hardDeletePomodoros() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 뽀모도로가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenDeletedPomodorosExist() { + // given + given(pomodoroCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L) + + // when + val result = pomodoroCommandService.hardDeletePomodoros() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeletePomodorosOwnedByDeletedDailyGoal 메서드는") + inner class HardDeletePomodorosOwnedByDeletedDailyGoal { + @Test + @DisplayName("삭제된 데일리 목표가 소유한 뽀모도로가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenPomodorosOwnedByDeletedDailyGoalDoNotExist() { + // given + given(pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(0L) + + // when + val result = pomodoroCommandService.hardDeletePomodorosOwnedByDeletedDailyGoal() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 데일리 목표가 소유한 뽀모도로가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenPomodorosOwnedByDeletedDailyGoalExist() { + // given + given(pomodoroCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(5L) + + // when + val result = pomodoroCommandService.hardDeletePomodorosOwnedByDeletedDailyGoal() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeletePomodorosByMember 메서드는") + inner class HardDeletePomodorosByMember { + @Test + @DisplayName("특정 멤버가 소유한 뽀모도로가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenPomodorosOwnedByMemberDoNotExist() { + // given + val memberId = -1L + given(pomodoroCommandRepository.deleteAllByMemberId(memberId)).willReturn(0L) + + // when + val result = pomodoroCommandService.hardDeletePomodorosByMember(memberId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("특정 멤버가 소유한 뽀모도로가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenPomodorosOwnedByMemberExist() { + // given + val memberId = member.id + given(pomodoroCommandRepository.deleteAllByMemberId(memberId)).willReturn(5L) + + // when + val result = pomodoroCommandService.hardDeletePomodorosByMember(memberId) + + // then + assertThat(result).isEqualTo(5L) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt new file mode 100644 index 0000000..3a5cfa9 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt @@ -0,0 +1,135 @@ +package com.ject.studytrip.pomodoro.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.global.exception.CustomException +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 +import com.ject.studytrip.trip.domain.model.Trip +import com.ject.studytrip.trip.domain.model.TripCategory +import com.ject.studytrip.trip.fixture.DailyGoalFixture +import com.ject.studytrip.trip.fixture.TripFixture +import org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import java.util.Optional + +@DisplayName("PomodoroQueryService 단위 테스트") +class PomodoroQueryServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var pomodoroQueryService: PomodoroQueryService + + @Mock + private lateinit var pomodoroRepository: PomodoroRepository + + @Mock + private lateinit var pomodoroQueryRepository: PomodoroQueryRepository + + private lateinit var trip: Trip + private lateinit var dailyGoal: DailyGoal + private lateinit var pomodoro: Pomodoro + + @BeforeEach + fun setUp() { + val member = MemberFixture.createMemberFromKakaoWithId(1L) + trip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE) + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, trip) + pomodoro = PomodoroFixture(dailyGoal).createWithId(1L) + } + + @Nested + @DisplayName("getValidPomodoroByDailyGoal 메서드는") + inner class GetValidPomodoroByDailyGoal { + @Test + @DisplayName("뽀모도로가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenPomodoroDoesNotExist() { + // given + val dailyGoalId = -1L + given(pomodoroRepository.findByDailyGoalId(dailyGoalId)).willReturn(Optional.empty()) + + // when + val exception = + assertThrows { + pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId) + } + + // then + assertThat(exception.message).isEqualTo(PomodoroErrorCode.POMODORO_NOT_FOUND.message) + } + + @Test + @DisplayName("뽀모도로가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenPomodoroAlreadyDeleted() { + // given + val dailyGoalId = dailyGoal.id + pomodoro.updateDeletedAt() + given(pomodoroRepository.findByDailyGoalId(dailyGoalId)).willReturn(Optional.of(pomodoro)) + + // when + val exception = + assertThrows { + pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId) + } + + // then + assertThat(exception.message).isEqualTo(PomodoroErrorCode.POMODORO_ALREADY_DELETED.message) + } + + @Test + @DisplayName("데일리 목표 ID로 뽀모도로를 조회하고 반환한다.") + fun shouldReturnPomodoroByDailyGoalId() { + // given + val dailyGoalId = dailyGoal.id + given(pomodoroRepository.findByDailyGoalId(dailyGoalId)).willReturn(Optional.of(pomodoro)) + + // when + val result = pomodoroQueryService.getValidPomodoroByDailyGoal(dailyGoalId) + + // then + assertThat(result).isEqualTo(pomodoro) + assertThat(result.dailyGoal.id).isEqualTo(dailyGoalId) + } + } + + @Nested + @DisplayName("getTotalFocusHoursByTripId 메서드는") + inner class GetTotalFocusHoursByTripId { + @Test + @DisplayName("유효하지 않은 여행 ID가 들어오면 0을 반환한다.") + fun shouldReturnZeroWhenTripIdIsInvalid() { + // given + val tripId = -1L + given(pomodoroQueryRepository.sumFocusHoursByTripId(tripId)).willReturn(0L) + + // when + val result = pomodoroQueryService.getTotalFocusHoursByTripId(tripId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("유효한 여행 ID가 들어오면 총 집중 시간(시간 단위)을 반환한다.") + fun shouldReturnTotalFocusHoursWhenTripIdIsValid() { + // given + val tripId = trip.id + given(pomodoroQueryRepository.sumFocusHoursByTripId(tripId)).willReturn(100L) + + // when + val result = pomodoroQueryService.getTotalFocusHoursByTripId(tripId) + + // then + assertThat(result).isEqualTo(100L) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/CreatePomodoroRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/CreatePomodoroRequestFixture.kt new file mode 100644 index 0000000..69504be --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/CreatePomodoroRequestFixture.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.pomodoro.fixture + +import com.ject.studytrip.pomodoro.presentation.dto.request.CreatePomodoroRequest + +class CreatePomodoroRequestFixture { + var focusDurationInMinute: Int = 25 + var focusSessionCount: Int = 4 + + fun build(): CreatePomodoroRequest = CreatePomodoroRequest(focusDurationInMinute, focusSessionCount) +} diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.kt new file mode 100644 index 0000000..23cba8a --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/fixture/PomodoroFixture.kt @@ -0,0 +1,27 @@ +package com.ject.studytrip.pomodoro.fixture + +import com.ject.studytrip.pomodoro.domain.factory.PomodoroFactory +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.test.util.ReflectionTestUtils + +class PomodoroFixture( + private val dailyGoal: DailyGoal, +) { + var focusDurationSeconds: Int = 30 * 60 + var focusSessionCount: Int = 1 + var breakDurationSeconds: Int = 0 + + fun create(): Pomodoro = + PomodoroFactory.create( + dailyGoal, + focusDurationSeconds, + focusSessionCount, + breakDurationSeconds, + ) + + fun createWithId(id: Long): Pomodoro = + create().also { + ReflectionTestUtils.setField(it, "id", id) + } +} diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.kt new file mode 100644 index 0000000..2461960 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/helper/PomodoroTestHelper.kt @@ -0,0 +1,25 @@ +package com.ject.studytrip.pomodoro.helper + +import com.ject.studytrip.pomodoro.domain.model.Pomodoro +import com.ject.studytrip.pomodoro.domain.repository.PomodoroRepository +import com.ject.studytrip.pomodoro.fixture.PomodoroFixture +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.stereotype.Component + +@Component +class PomodoroTestHelper( + private val pomodoroRepository: PomodoroRepository, +) { + fun savePomodoro(dailyGoal: DailyGoal): Pomodoro { + val pomodoro = PomodoroFixture(dailyGoal).create() + return pomodoroRepository.save(pomodoro) + } + + fun saveDeletedPomodoro(dailyGoal: DailyGoal): Pomodoro { + val pomodoro = + PomodoroFixture(dailyGoal).create().also { + it.updateDeletedAt() + } + return pomodoroRepository.save(pomodoro) + } +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt new file mode 100644 index 0000000..0f527c5 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt @@ -0,0 +1,235 @@ +package com.ject.studytrip.studylog.application.service + +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.studylog.domain.error.StudyLogErrorCode +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.repository.StudyLogCommandRepository +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository +import com.ject.studytrip.studylog.fixture.StudyLogFixture +import com.ject.studytrip.trip.domain.model.DailyGoal +import com.ject.studytrip.trip.domain.model.Trip +import com.ject.studytrip.trip.domain.model.TripCategory +import com.ject.studytrip.trip.fixture.DailyGoalFixture +import com.ject.studytrip.trip.fixture.TripFixture +import org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.kotlin.any +import org.springframework.test.util.ReflectionTestUtils + +@DisplayName("StudyLogCommandService 단위 테스트") +class StudyLogCommandServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var studyLogCommandService: StudyLogCommandService + + @Mock + private lateinit var studyLogRepository: StudyLogRepository + + @Mock + private lateinit var studyLogCommandRepository: StudyLogCommandRepository + + private lateinit var member: Member + private lateinit var courseTrip: Trip + private lateinit var dailyGoal: DailyGoal + private lateinit var studyLog: StudyLog + + @BeforeEach + fun setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L) + courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE) + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip) + studyLog = StudyLogFixture(member, dailyGoal).createWithId(1L) + } + + @Nested + @DisplayName("createStudyLog 메서드는") + inner class CreateStudyLog { + @Test + @DisplayName("학습 로그를 생성하고 반환한다.") + fun shouldCreateAndReturnStudyLog() { + // given + val content = "TEST CONTENT" + given(studyLogRepository.save(any())) + .willAnswer { invocation -> + val studyLog = invocation.getArgument(0) + ReflectionTestUtils.setField(studyLog, "id", 1L) + studyLog + } + + // when + val result = studyLogCommandService.createStudyLog(member, dailyGoal, content) + + // then + assertThat(result.id).isEqualTo(1L) + assertThat(result.title).isEqualTo(dailyGoal.title) + assertThat(result.content).isEqualTo(content) + } + } + + @Nested + @DisplayName("updateImageUrl 메서드는") + inner class UpdateImageUrl { + private val newImageUrl = "https://cdn.example.com/study-logs/1/image.jpg" + + @Test + @DisplayName("삭제된 학습 로그의 이미지 URL을 수정하면 예외가 발생한다.") + fun shouldThrowExceptionWhenStudyLogIsDeleted() { + // given + studyLog.updateDeletedAt() + + // when + val exception = + assertThrows { + studyLogCommandService.updateImageUrl(studyLog, newImageUrl) + } + + // then + assertThat(exception.message).isEqualTo(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.message) + } + + @Test + @DisplayName("유효한 학습 로그의 이미지 URL을 수정한다.") + fun shouldUpdateStudyLogImageUrlWhenStudyLogIsValid() { + // given + val oldImageUrl = studyLog.imageUrl + + // when + studyLogCommandService.updateImageUrl(studyLog, newImageUrl) + + // then + assertThat(studyLog.imageUrl).isEqualTo(newImageUrl) + assertThat(studyLog.imageUrl).isNotEqualTo(oldImageUrl) + } + } + + @Nested + @DisplayName("hardDeleteStudyLogs 메서드는") + inner class HardDeleteStudyLogs { + @Test + @DisplayName("삭제된 학습 로그가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenDeletedStudyLogsDoNotExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogs() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 학습 로그가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenDeletedStudyLogsExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogs() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeleteStudyLogsOwnedByDeletedMember 메서드는") + inner class HardDeleteStudyLogsOwnedByDeletedMember { + @Test + @DisplayName("삭제된 멤버가 소유한 학습 로그가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenStudyLogsOwnedByDeletedMemberDoNotExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedMemberOwner()).willReturn(0L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedMember() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 멤버가 소유한 학습 로그가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenStudyLogsOwnedByDeletedMemberExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedMemberOwner()).willReturn(5L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedMember() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeleteStudyLogsOwnedByDeletedDailyGoal 메서드는") + inner class HardDeleteStudyLogsOwnedByDeletedDailyGoal { + @Test + @DisplayName("삭제된 데일리 목표가 소유한 학습 로그가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenStudyLogsOwnedByDeletedDailyGoalDoNotExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(0L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedDailyGoal() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 데일리 목표가 소유한 학습 로그가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenStudyLogsOwnedByDeletedDailyGoalExist() { + // given + given(studyLogCommandRepository.deleteAllByDeletedDailyGoalOwner()).willReturn(5L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedDailyGoal() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeleteStudyLogsByMember 메서드는") + inner class HardDeleteStudyLogsByMember { + @Test + @DisplayName("특정 멤버가 소유한 학습 로그가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenStudyLogsOwnedByMemberDoNotExist() { + // given + val memberId = member.id + given(studyLogCommandRepository.deleteByMemberId(memberId)).willReturn(0L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("특정 멤버가 소유한 학습 로그가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenStudyLogsOwnedByMemberExist() { + // given + val memberId = member.id + given(studyLogCommandRepository.deleteByMemberId(memberId)).willReturn(5L) + + // when + val result = studyLogCommandService.hardDeleteStudyLogsByMember(memberId) + + // then + assertThat(result).isEqualTo(5L) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt new file mode 100644 index 0000000..2d878af --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt @@ -0,0 +1,326 @@ +package com.ject.studytrip.studylog.application.service + +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.studylog.domain.error.StudyLogErrorCode +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository +import com.ject.studytrip.studylog.fixture.StudyLogFixture +import com.ject.studytrip.trip.domain.model.DailyGoal +import com.ject.studytrip.trip.domain.model.Trip +import com.ject.studytrip.trip.domain.model.TripCategory +import com.ject.studytrip.trip.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 org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.SliceImpl +import java.util.Optional + +@DisplayName("StudyLogQueryService 단위 테스트") +class StudyLogQueryServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var studyLogQueryService: StudyLogQueryService + + @Mock + private lateinit var studyLogRepository: StudyLogRepository + + @Mock + private lateinit var studyLogQueryRepository: StudyLogQueryRepository + + private lateinit var member: Member + private lateinit var courseTrip: Trip + private lateinit var dailyGoal: DailyGoal + private lateinit var studyLog1: StudyLog + private lateinit var studyLog2: StudyLog + private lateinit var tripReport: TripReport + + companion object { + private const val DEFAULT_PAGE = 0 + private const val DEFAULT_SIZE = 5 + } + + private val pageable: Pageable = PageRequest.of(DEFAULT_PAGE, DEFAULT_SIZE) + + @BeforeEach + fun setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L) + courseTrip = TripFixture.createTripWithId(1L, member, TripCategory.COURSE) + dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip) + studyLog1 = StudyLogFixture(member, dailyGoal).createWithId(1L) + studyLog2 = StudyLogFixture(member, dailyGoal).createWithId(2L) + tripReport = TripReportFixture.createTripReportWithId(1L, member) + } + + @Nested + @DisplayName("getActiveStudyLogCountByMemberId 메서드는") + inner class GetActiveStudyLogCountByMemberId { + @Test + @DisplayName("특정 멤버에 대한 학습 기록이 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenStudyLogForMemberDoesNotExist() { + // given + val memberId = member.id + given(studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId)).willReturn(0L) + + // when + val result = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("특정 멤버에 대한 학습 기록이 존재하면 그 개수를 반환한다.") + fun shouldReturnCountWhenStudyLogForMemberExists() { + // given + val memberId = member.id + given(studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId)).willReturn(2L) + + // when + val result = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId) + + // then + assertThat(result).isEqualTo(2L) + } + } + + @Nested + @DisplayName("getStudyLogsSliceByTripId 메서드는") + inner class GetStudyLogsSliceByTripId { + @Test + @DisplayName("특정 여행에 대한 학습 로그 목록을 페이징 처리와 최신순으로 정렬하여 반환한다.") + fun shouldReturnStudyLogsByTripIdPagedAndSortedByLatest() { + // given + val tripId = courseTrip.id + val order = "LATEST" + val studyLogs = listOf(studyLog1, studyLog2) + val mockSlice = SliceImpl(studyLogs, pageable, false) + given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)).willReturn(mockSlice) + + // when + val result = studyLogQueryService.getStudyLogsSliceByTripId(tripId, DEFAULT_PAGE, DEFAULT_SIZE, order) + + // then + assertThat(result.content).hasSize(studyLogs.size) + assertThat(result.content[0]).isEqualTo(studyLog1) + assertThat(result.content[1]).isEqualTo(studyLog2) + } + + @Test + @DisplayName("특정 여행에 대한 학습 로그 목록을 페이징 처리와 과거순으로 정렬하여 반환한다.") + fun shouldReturnStudyLogsByTripIdPagedAndSortedByOldest() { + // given + val tripId = courseTrip.id + val order = "OLDEST" + val studyLogs = listOf(studyLog2, studyLog1) // 과거순이므로 순서 반대 + val mockSlice = SliceImpl(studyLogs, pageable, false) + given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)).willReturn(mockSlice) + + // when + val result = studyLogQueryService.getStudyLogsSliceByTripId(tripId, DEFAULT_PAGE, DEFAULT_SIZE, order) + + // then + assertThat(result.content).hasSize(studyLogs.size) + assertThat(result.content[0]).isEqualTo(studyLog2) + assertThat(result.content[1]).isEqualTo(studyLog1) + } + } + + @Nested + @DisplayName("getValidStudyLog 메서드는") + inner class GetValidStudyLog { + @Test + @DisplayName("학습 로그가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenStudyLogDoesNotExist() { + // given + val studyLogId = -1L + given(studyLogRepository.findById(studyLogId)).willReturn(Optional.empty()) + + // when + val exception = assertThrows { studyLogQueryService.getValidStudyLog(studyLogId) } + + // then + assertThat(exception.message).isEqualTo(StudyLogErrorCode.STUDY_LOG_NOT_FOUND.message) + } + + @Test + @DisplayName("학습 로그가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenStudyLogAlreadyDeleted() { + // given + val studyLogId = studyLog1.id + studyLog1.updateDeletedAt() + given(studyLogRepository.findById(studyLogId)).willReturn(Optional.of(studyLog1)) + + // when + val exception = assertThrows { studyLogQueryService.getValidStudyLog(studyLogId) } + + // then + assertThat(exception.message).isEqualTo(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.message) + } + + @Test + @DisplayName("학습 로그가 존재하면 학습 로그를 반환한다.") + fun shouldReturnStudyLogWhenStudyLogExists() { + // given + val studyLogId = studyLog1.id + given(studyLogRepository.findById(studyLogId)).willReturn(Optional.of(studyLog1)) + + // when + val result = studyLogQueryService.getValidStudyLog(studyLogId) + + // then + assertThat(result).isEqualTo(studyLog1) + } + } + + @Nested + @DisplayName("getStudyLogCountByTripId 메서드는") + inner class GetStudyLogCountByTripId { + @Test + @DisplayName("특정 여행에 대한 학습 로그가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenStudyLogForTripDoesNotExist() { + // given + val tripId = -1L + given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(0L) + + // when + val result = studyLogQueryService.getStudyLogCountByTripId(tripId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("특정 여행에 대한 데일리 목표가 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenDailyGoalForTripDoesNotExist() { + // given + val tripId = courseTrip.id + dailyGoal.updateDeletedAt() + given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(0L) + + // when + val result = studyLogQueryService.getStudyLogCountByTripId(tripId) + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("특정 여행에 대한 학습 로그가 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenStudyLogForTripExists() { + // given + val tripId = courseTrip.id + given(studyLogQueryRepository.countStudyLogsByTripId(tripId)).willReturn(2L) + + // when + val result = studyLogQueryService.getStudyLogCountByTripId(tripId) + + // then + assertThat(result).isEqualTo(2L) + } + } + + @Nested + @DisplayName("getStudyLogsSliceByTripReportId 메서드는") + inner class GetStudyLogsSliceByTripReportId { + @Test + @DisplayName("특정 여행 리포트에 대한 학습 로그 목록을 페이징 처리와 최신순으로 정렬하여 반환한다.") + fun shouldReturnStudyLogsByTripReportIdPagedAndSortedByLatest() { + // given + val tripReportId = tripReport.id + val studyLogs = listOf(studyLog1, studyLog2) + val mockSlice = SliceImpl(studyLogs, pageable, false) + given(studyLogQueryRepository.findSliceByTripReportIdOrderByCreatedAtDesc(tripReportId, pageable)).willReturn(mockSlice) + + // when + val result = studyLogQueryService.getStudyLogsSliceByTripReportId(tripReportId, DEFAULT_PAGE, DEFAULT_SIZE) + + // then + assertThat(result.content).hasSize(studyLogs.size) + assertThat(result.content[0]).isEqualTo(studyLog1) + assertThat(result.content[1]).isEqualTo(studyLog2) + } + } + + @Nested + @DisplayName("getStudyLogIdsByTripId 메서드는") + inner class GetStudyLogIdsByTripId { + @Test + @DisplayName("학습 로그가 존재하지 않으면 빈 리스트를 반환한다.") + fun shouldReturnEmptyListWhenStudyLogDoesNotExist() { + // given + val tripId = -1L + given(studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId)).willReturn(emptyList()) + + // when + val result = studyLogQueryService.getStudyLogIdsByTripId(tripId) + + // then + assertThat(result).isEmpty() + } + + @Test + @DisplayName("학습 로그가 하나라도 존재하면 학습 로그 ID 목록을 반환한다.") + fun shouldReturnStudyLogIdsWhenStudyLogExists() { + // given + val tripId = courseTrip.id + val studyLogIds = listOf(studyLog1.id, studyLog2.id) + given(studyLogQueryRepository.findAllIdsByTripIdOrderByCreatedDesc(tripId)).willReturn(studyLogIds) + + // when + val result = studyLogQueryService.getStudyLogIdsByTripId(tripId) + + // then + assertThat(result).hasSize(studyLogIds.size) + assertThat(result[0]).isEqualTo(studyLog1.id) + assertThat(result[1]).isEqualTo(studyLog2.id) + } + } + + @Nested + @DisplayName("getStudyLogImageUrlsByMemberId 메서드는") + inner class GetStudyLogImageUrlsByMemberId { + @Test + @DisplayName("이미지가 존재하지 않으면 빈 리스트를 반환한다.") + fun shouldReturnEmptyListWhenImagesDoNotExist() { + // given + val memberId = member.id + given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(emptyList()) + + // when + val result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId) + + // then + assertThat(result).isEmpty() + } + + @Test + @DisplayName("이미지가 존재하면 학습 로그 이미지 URL 목록을 반환한다.") + fun shouldReturnStudyLogImageUrlsWhenImagesExist() { + // given + val memberId = member.id + val imageUrls = listOf("https://cdn.example.com/studylogs/1.jpg", "https://cdn.example.com/studylogs/2.jpg") + given(studyLogQueryRepository.findImageUrlsByMemberId(memberId)).willReturn(imageUrls) + + // when + val result = studyLogQueryService.getStudyLogImageUrlsByMemberId(memberId) + + // then + assertThat(result).hasSize(imageUrls.size) + assertThat(result).isEqualTo(imageUrls) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/fixture/ConfirmStudyLogImageRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/studylog/fixture/ConfirmStudyLogImageRequestFixture.kt new file mode 100644 index 0000000..0c9f72b --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/fixture/ConfirmStudyLogImageRequestFixture.kt @@ -0,0 +1,11 @@ +package com.ject.studytrip.studylog.fixture + +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest + +class ConfirmStudyLogImageRequestFixture { + var tmpKey: String = "tmp/study-logs/1/test.jpg" + + fun withTmpKey(tmpKey: String): ConfirmStudyLogImageRequestFixture = apply { this.tmpKey = tmpKey } + + fun build(): ConfirmStudyLogImageRequest = ConfirmStudyLogImageRequest(tmpKey) +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.kt new file mode 100644 index 0000000..e0691f9 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/fixture/CreateStudyLogRequestFixture.kt @@ -0,0 +1,20 @@ +package com.ject.studytrip.studylog.fixture + +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest + +class CreateStudyLogRequestFixture { + var totalFocusTimeInSeconds: Int = 60 + var selectedDailyMissionIds: List = emptyList() + var content: String = "TEST 학습 로그 내용" + + fun withTotalFocusTimeInMinutes(minutes: Int): CreateStudyLogRequestFixture = apply { this.totalFocusTimeInSeconds = minutes * 60 } + + fun withSelectedDailyMissionIds(ids: List): CreateStudyLogRequestFixture = apply { this.selectedDailyMissionIds = ids.toList() } + + fun build(): CreateStudyLogRequest = + CreateStudyLogRequest( + totalFocusTimeInSeconds, + selectedDailyMissionIds, + content, + ) +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/fixture/PresignStudyLogImageRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/studylog/fixture/PresignStudyLogImageRequestFixture.kt new file mode 100644 index 0000000..b73c7f9 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/fixture/PresignStudyLogImageRequestFixture.kt @@ -0,0 +1,11 @@ +package com.ject.studytrip.studylog.fixture + +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest + +class PresignStudyLogImageRequestFixture { + var originFilename: String = "test.jpg" + + fun withOriginFilename(originFilename: String): PresignStudyLogImageRequestFixture = apply { this.originFilename = originFilename } + + fun build(): PresignStudyLogImageRequest = PresignStudyLogImageRequest(originFilename) +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/fixture/StudyLogFixture.kt b/src/test/kotlin/com/ject/studytrip/studylog/fixture/StudyLogFixture.kt new file mode 100644 index 0000000..29a270a --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/fixture/StudyLogFixture.kt @@ -0,0 +1,21 @@ +package com.ject.studytrip.studylog.fixture + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.studylog.domain.factory.StudyLogFactory +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.test.util.ReflectionTestUtils + +class StudyLogFixture( + private val member: Member, + private val dailyGoal: DailyGoal, +) { + var content: String = "TEST 학습 로그 내용" + + fun create(): StudyLog = StudyLogFactory.create(member, dailyGoal, content) + + fun createWithId(id: Long): StudyLog = + create().also { + ReflectionTestUtils.setField(it, "id", id) + } +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/helper/StudyLogTestHelper.kt b/src/test/kotlin/com/ject/studytrip/studylog/helper/StudyLogTestHelper.kt new file mode 100644 index 0000000..c42aa5f --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/helper/StudyLogTestHelper.kt @@ -0,0 +1,18 @@ +package com.ject.studytrip.studylog.helper + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.domain.repository.StudyLogRepository +import com.ject.studytrip.studylog.fixture.StudyLogFixture +import com.ject.studytrip.trip.domain.model.DailyGoal +import org.springframework.stereotype.Component + +@Component +class StudyLogTestHelper( + private val studyLogRepository: StudyLogRepository, +) { + fun saveStudyLog( + member: Member, + dailyGoal: DailyGoal, + ): StudyLog = studyLogRepository.save(StudyLogFixture(member, dailyGoal).create()) +} diff --git a/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt new file mode 100644 index 0000000..23ed59a --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt @@ -0,0 +1,861 @@ +package com.ject.studytrip.studylog.presentation.controller + +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.domain.model.MemberRole +import com.ject.studytrip.member.helper.MemberTestHelper +import com.ject.studytrip.mission.domain.error.DailyMissionErrorCode +import com.ject.studytrip.mission.domain.error.MissionErrorCode +import com.ject.studytrip.mission.domain.model.DailyMission +import com.ject.studytrip.mission.domain.model.Mission +import com.ject.studytrip.mission.helper.DailyMissionTestHelper +import com.ject.studytrip.mission.helper.MissionTestHelper +import com.ject.studytrip.pomodoro.domain.error.PomodoroErrorCode +import com.ject.studytrip.pomodoro.helper.PomodoroTestHelper +import com.ject.studytrip.stamp.domain.model.Stamp +import com.ject.studytrip.stamp.helper.StampTestHelper +import com.ject.studytrip.studylog.domain.model.StudyLog +import com.ject.studytrip.studylog.fixture.ConfirmStudyLogImageRequestFixture +import com.ject.studytrip.studylog.fixture.CreateStudyLogRequestFixture +import com.ject.studytrip.studylog.fixture.PresignStudyLogImageRequestFixture +import com.ject.studytrip.studylog.helper.StudyLogDailyMissionTestHelper +import com.ject.studytrip.studylog.helper.StudyLogTestHelper +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest +import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest +import com.ject.studytrip.trip.domain.error.DailyGoalErrorCode +import com.ject.studytrip.trip.domain.error.TripErrorCode +import com.ject.studytrip.trip.domain.model.DailyGoal +import com.ject.studytrip.trip.domain.model.Trip +import com.ject.studytrip.trip.domain.model.TripCategory +import com.ject.studytrip.trip.helper.DailyGoalTestHelper +import com.ject.studytrip.trip.helper.TripTestHelper +import org.assertj.core.api.Assertions.assertThat +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.mockito.ArgumentMatchers.anyString +import org.mockito.BDDMockito.given +import org.mockito.Mockito.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@DisplayName("StudyLogController 통합 테스트") +class StudyLogControllerIntegrationTest : BaseIntegrationTest() { + @Autowired private lateinit var memberTestHelper: MemberTestHelper + + @Autowired private lateinit var tokenTestHelper: TokenTestHelper + + @Autowired private lateinit var tripTestHelper: TripTestHelper + + @Autowired private lateinit var stampTestHelper: StampTestHelper + + @Autowired private lateinit var missionTestHelper: MissionTestHelper + + @Autowired private lateinit var dailyGoalTestHelper: DailyGoalTestHelper + + @Autowired private lateinit var dailyMissionTestHelper: DailyMissionTestHelper + + @Autowired private lateinit var studyLogTestHelper: StudyLogTestHelper + + @Autowired private lateinit var studyLogDailyMissionTestHelper: StudyLogDailyMissionTestHelper + + @Autowired private lateinit var pomodoroTestHelper: PomodoroTestHelper + + @MockitoBean private lateinit var s3ImageStorageProvider: S3ImageStorageProvider + + private lateinit var member: Member + private lateinit var token: String + private lateinit var trip: Trip + private lateinit var stamp: Stamp + private lateinit var mission: Mission + private lateinit var dailyGoal: DailyGoal + private lateinit var dailyMission: DailyMission + private lateinit var studyLog: StudyLog + + companion object { + private const val DEFAULT_PAGE: String = "0" + private const val DEFAULT_SIZE: String = "5" + private const val ORDER_LATEST: String = "LATEST" + private const val ORDER_OLDEST: String = "OLDEST" + } + + @BeforeEach + fun setUp() { + member = memberTestHelper.saveMember() + token = tokenTestHelper.createAccessToken(member.id.toString(), MemberRole.ROLE_USER.name) + trip = tripTestHelper.saveTrip(member, TripCategory.COURSE) + stamp = stampTestHelper.saveStamp(trip, 1) + mission = missionTestHelper.saveMission(stamp) + dailyGoal = dailyGoalTestHelper.saveDailyGoal(trip) + dailyMission = dailyMissionTestHelper.saveDailyMission(mission, dailyGoal) + pomodoroTestHelper.savePomodoro(dailyGoal) + studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal) + } + + @Nested + @DisplayName("학습 로그 생성 API") + inner class CreateStudyLog { + private val fixture = CreateStudyLogRequestFixture() + + private fun getResultActions( + token: String, + tripId: Any, + dailyGoalId: Any, + request: CreateStudyLogRequest, + ): ResultActions = + mockMvc.perform( + post("/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs", tripId, dailyGoalId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions("", trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripIdTypeMismatch() { + // given + val tripId = "abc" + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, tripId, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.message)) + } + + @Test + @DisplayName("PathVariable 데일리 목표 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenDailyGoalIdTypeMismatch() { + // given + val dailyGoalId = "abc" + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoalId, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.message)) + } + + @Test + @DisplayName("요청이 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenRequestIsInvalid() { + // given + val request = + fixture + .withTotalFocusTimeInMinutes(-30) + .withSelectedDailyMissionIds(listOf()) + .build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + + @Test + @DisplayName("여행이 존재하지 않으면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenTripDoesNotExist() { + // given + val tripId = -1L + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, tripId, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_NOT_FOUND.message)) + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + fun shouldReturnForbiddenWhenMemberIsNotTripOwner() { + // given + val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, newTrip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.NOT_TRIP_OWNER.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.NOT_TRIP_OWNER.message)) + } + + @Test + @DisplayName("여행이 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripAlreadyDeleted() { + // given + val deletedTrip = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, deletedTrip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("여행이 이미 완료되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripAlreadyCompleted() { + // given + val completedTrip = tripTestHelper.saveCompletedTrip(member, TripCategory.COURSE) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, completedTrip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_ALREADY_COMPLETED.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_ALREADY_COMPLETED.message)) + } + + @Test + @DisplayName("데일리 목표가 존재하지 않으면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenDailyGoalDoesNotExist() { + // given + val dailyGoalId = -1L + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoalId, request) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyGoalErrorCode.DAILY_GOAL_NOT_FOUND.message)) + } + + @Test + @DisplayName("데일리 목표가 요청한 여행에 속하지 않으면 403 Forbidden을 반환한다.") + fun shouldReturnForbiddenWhenDailyGoalDoesNotBelongToTrip() { + // given + val newTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE) + val newDailyGoal = dailyGoalTestHelper.saveDailyGoal(newTrip) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, newDailyGoal.id, request) + + // then + resultActions + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyGoalErrorCode.DAILY_GOAL_NOT_BELONG_TO_TRIP.message)) + } + + @Test + @DisplayName("데일리 목표가 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenDailyGoalAlreadyDeleted() { + // given + val deletedDailyGoal = dailyGoalTestHelper.saveDeletedDailyGoal(trip) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, deletedDailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyGoalErrorCode.DAILY_GOAL_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("선택한 데일리 미션 ID 목록 중 존재하지 않는 값이 포함되어 있다면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenSelectedDailyMissionIdsContainInvalidId() { + // given + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id, 1000L)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyMissionErrorCode.DAILY_MISSION_NOT_FOUND.message)) + } + + @Test + @DisplayName("데일리 미션이 요청한 데일리 목표에 속하지 않으면 403 Forbidden을 반환한다.") + fun shouldReturnForbiddenWhenDailyMissionDoesNotBelongToDailyGoal() { + // given + val newDailyGoal = dailyGoalTestHelper.saveDailyGoal(trip) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, newDailyGoal.id, request) + + // then + resultActions + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyMissionErrorCode.DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyMissionErrorCode.DAILY_MISSION_NOT_BELONG_TO_DAILY_GOAL.message)) + } + + @Test + @DisplayName("데일리 미션이 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenDailyMissionAlreadyDeleted() { + // given + val deletedDailyMission = dailyMissionTestHelper.saveDeletedDailyMission(mission, dailyGoal) + val request = fixture.withSelectedDailyMissionIds(listOf(deletedDailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(DailyMissionErrorCode.DAILY_MISSION_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("특정 데일리 목표에 속한 뽀모도로가 존재하지 않으면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenPomodoroForDailyGoalDoesNotExist() { + // given + val newDailyGoal = dailyGoalTestHelper.saveDailyGoal(trip) + val dailyMission = dailyMissionTestHelper.saveDailyMission(mission, newDailyGoal) + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, newDailyGoal.id, request) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(PomodoroErrorCode.POMODORO_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(PomodoroErrorCode.POMODORO_NOT_FOUND.message)) + } + + @Test + @DisplayName("특정 데일리 목표에 속한 뽀모도로가 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenPomodoroForDailyGoalAlreadyDeleted() { + // given + val newDailyGoal = dailyGoalTestHelper.saveDailyGoal(trip) + val newDailyMission = dailyMissionTestHelper.saveDailyMission(mission, newDailyGoal) + pomodoroTestHelper.saveDeletedPomodoro(newDailyGoal) + val request = fixture.withSelectedDailyMissionIds(listOf(newDailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, newDailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(PomodoroErrorCode.POMODORO_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(PomodoroErrorCode.POMODORO_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("선택한 미션 목록에 이미 삭제된 미션이 포함되어 있다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenSelectedMissionAlreadyDeleted() { + // given + val deletedMission = missionTestHelper.saveDeletedMission(stamp) + val newDailyMission = dailyMissionTestHelper.saveDailyMission(deletedMission, dailyGoal) + val request = fixture.withSelectedDailyMissionIds(listOf(newDailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MissionErrorCode.MISSION_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MissionErrorCode.MISSION_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("선택한 미션 목록에 이미 완료된 미션이 포함되어 있다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenSelectedMissionAlreadyCompleted() { + // given + val completedMission = missionTestHelper.saveCompletedMission(stamp) + val newDailyMission = dailyMissionTestHelper.saveDailyMission(completedMission, dailyGoal) + val request = fixture.withSelectedDailyMissionIds(listOf(newDailyMission.id)).build() + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MissionErrorCode.MISSION_ALREADY_COMPLETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MissionErrorCode.MISSION_ALREADY_COMPLETED.message)) + } + + @Test + @DisplayName("유효한 요청이 들어오면 학습 로그를 생성하고 반환한다.") + fun shouldReturnStudyLogWhenRequestIsValid() { + // given + val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() + val initialCompletedMissions = stamp.completedMissions + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isCreated) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogId").isNumber) + + // 스탬프의 완료된 미션 수 증가 확인 + val updatedStamp = stampTestHelper.getStamp(stamp.id) + assertThat(updatedStamp.completedMissions).isEqualTo(initialCompletedMissions + 1) + } + + @Test + @DisplayName("여러 미션을 선택하면 스탬프의 완료된 미션 수가 증가한다.") + fun shouldUpdateCompletedMissionsWhenMultipleMissionsSelected() { + // given + val newMission = missionTestHelper.saveMission(stamp) + val newDailyMission = dailyMissionTestHelper.saveDailyMission(newMission, dailyGoal) + val request = + fixture + .withSelectedDailyMissionIds(listOf(dailyMission.id, newDailyMission.id)) + .build() + val initialCompletedMissions = stamp.completedMissions + + // when + val resultActions = getResultActions(token, trip.id, dailyGoal.id, request) + + // then + resultActions + .andExpect(status().isCreated) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogId").isNumber) + + // 스탬프의 완료된 미션 수 증가 확인 + val updatedStamp = stampTestHelper.getStamp(stamp.id) + assertThat(updatedStamp.completedMissions).isEqualTo(initialCompletedMissions + 2) + } + } + + @Nested + @DisplayName("학습 로그 목록 조회 API") + inner class ListStudyLogs { + private fun getResultActions( + token: String, + tripId: Any, + page: String, + size: String, + order: String, + ): ResultActions = + mockMvc.perform( + get("/api/trips/{tripId}/study-logs", tripId) + .param("page", page) + .param("size", size) + .param("order", order) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // when + val resultActions = getResultActions("", trip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripIdTypeMismatch() { + // given + val tripId = "abc" + + // when + val resultActions = getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.message)) + } + + @Test + @DisplayName("Request Param 페이징 데이터 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenPagingParameterTypeMismatch() { + // given + val page = "abc" + val size = "abc" + + // when + val resultActions = getResultActions(token, trip.id, page, size, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.message)) + } + + @Test + @DisplayName("Request Param 페이징 데이터가 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenPagingParameterIsInvalid() { + // given + val page = "-1" + val size = "-1" + + // when + val resultActions = getResultActions(token, trip.id, page, size, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.CONSTRAINT_VIOLATION.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.CONSTRAINT_VIOLATION.message)) + } + + @Test + @DisplayName("여행이 존재하지 않으면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenTripDoesNotExist() { + // given + val tripId = -1L + + // when + val resultActions = getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_NOT_FOUND.message)) + } + + @Test + @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") + fun shouldReturnForbiddenWhenMemberIsNotTripOwner() { + // given + val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) + // when + val resultActions = getResultActions(token, newTrip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.NOT_TRIP_OWNER.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.NOT_TRIP_OWNER.message)) + } + + @Test + @DisplayName("여행이 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripAlreadyDeleted() { + // given + val deletedTrip = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE) + + // when + val resultActions = getResultActions(token, deletedTrip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("여행이 이미 완료되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTripAlreadyCompleted() { + // given + val completedTrip = tripTestHelper.saveCompletedTrip(member, TripCategory.COURSE) + + // when + val resultActions = getResultActions(token, completedTrip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(TripErrorCode.TRIP_ALREADY_COMPLETED.status.value())) + .andExpect(jsonPath("$.data.message").value(TripErrorCode.TRIP_ALREADY_COMPLETED.message)) + } + + @Test + @DisplayName("order 파라미터가 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenOrderIsInvalid() { + // given + val order = "INVALID" + + // when + val resultActions = getResultActions(token, trip.id, DEFAULT_PAGE, DEFAULT_SIZE, order) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.CONSTRAINT_VIOLATION.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.CONSTRAINT_VIOLATION.message)) + } + + @Test + @DisplayName("order 파라미터를 LATEST로 지정하면, 최신순으로 정렬된 학습 로그 목록을 슬라이스 처리하여 반환한다.") + fun shouldReturnStudyLogSliceByTripOrderedByLatest() { + // given + studyLogDailyMissionTestHelper.saveStudyLogDailyMissions(studyLog, dailyMission) + + // when + val resultActions = getResultActions(token, trip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogs").isNotEmpty) + .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.id)) + .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("order 파라미터를 OLDEST로 지정하면, 과거순으로 정렬된 학습 로그 목록을 슬라이스 처리하여 반환한다.") + fun shouldReturnStudyLogSliceByTripOrderedByOldest() { + // given + studyLogDailyMissionTestHelper.saveStudyLogDailyMissions(studyLog, dailyMission) + + // when + val resultActions = getResultActions(token, trip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_OLDEST) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogs").isNotEmpty) + .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.id)) + .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + } + + @Nested + @DisplayName("학습 로그 이미지 Presigned URL 발급 API") + inner class IssuePresignedUrl { + private val fixture = PresignStudyLogImageRequestFixture() + + private fun getResultActions( + token: String, + studyLogId: Long, + request: PresignStudyLogImageRequest, + ): ResultActions = + mockMvc.perform( + post("/api/study-logs/{studyLogId}/images/presigned", studyLogId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions("", studyLog.id, request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenFilenameIsEmpty() { + // given + val request = fixture.withOriginFilename("").build() + + // when + val resultActions = getResultActions(token, studyLog.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + + @Test + @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenExtensionIsInvalid() { + // given + val request = fixture.withOriginFilename("test.pdf").build() + + // when + val resultActions = getResultActions(token, studyLog.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(ImageErrorCode.INVALID_IMAGE_EXTENSION.status.value())) + .andExpect(jsonPath("$.data.message").value(ImageErrorCode.INVALID_IMAGE_EXTENSION.message)) + } + + @Test + @DisplayName("파일명이 유효하면 Presigned URL을 발급한다.") + fun shouldIssuePresignedUrlWhenFilenameIsValid() { + // given + val request = fixture.build() + given(s3ImageStorageProvider.issuePresignedUrl(anyString())).willReturn("https://mocked-presigned-url.com") + + // when + val resultActions = getResultActions(token, studyLog.id, request) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty) + .andExpect(jsonPath("$.data.tmpKey").isNotEmpty) + .andExpect(jsonPath("$.data.tmpKey").value(Matchers.startsWith("tmp/study-logs/"))) + .andExpect(jsonPath("$.data.tmpKey").value(Matchers.containsString(studyLog.id.toString()))) + + // S3Provider 호출 검증 + verify(s3ImageStorageProvider).issuePresignedUrl(anyString()) + } + } + + @Nested + @DisplayName("학습 로그 이미지 확정 API") + inner class ConfirmImage { + private val fixture = ConfirmStudyLogImageRequestFixture() + + private fun getResultActions( + token: String, + studyLogId: Long, + request: ConfirmStudyLogImageRequest, + ): ResultActions = + mockMvc.perform( + post("/api/study-logs/{studyLogId}/images/confirm", studyLogId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions("", studyLog.id, request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTmpKeyIsEmpty() { + // given + val request = fixture.withTmpKey("").build() + + // when + val resultActions = getResultActions(token, studyLog.id, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + } +}