diff --git a/src/main/java/com/example/hackathon/domain/detox/controller/DetoxProgressController.java b/src/main/java/com/example/hackathon/domain/detox/controller/DetoxProgressController.java new file mode 100644 index 0000000..389a34d --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/detox/controller/DetoxProgressController.java @@ -0,0 +1,29 @@ +package com.example.hackathon.domain.detox.controller; + +import com.example.hackathon.domain.detox.dto.DetoxProgressResponse; +import com.example.hackathon.domain.detox.service.DetoxProgressService; +import com.example.hackathon.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Detox", description = "디지털 디톡스 진행 관련 API") +@RestController +@RequestMapping("/api/detox") +@RequiredArgsConstructor +public class DetoxProgressController { + + private final DetoxProgressService detoxProgressService; + + @Operation(summary = "디톡스 진행 상태 조회", description = "디톡스 진행 상태와 시간이 겹치는 팀원을 조회합니다.") + @GetMapping("/progress") + public ApiResponse getProgress( + @RequestHeader("X-Device-Id") String deviceId + ) { + return ApiResponse.ok("디톡스 진행 상태 조회 성공", detoxProgressService.getProgress(deviceId)); + } +} diff --git a/src/main/java/com/example/hackathon/domain/detox/dto/DetoxProgressResponse.java b/src/main/java/com/example/hackathon/domain/detox/dto/DetoxProgressResponse.java new file mode 100644 index 0000000..aa0faaf --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/detox/dto/DetoxProgressResponse.java @@ -0,0 +1,22 @@ +package com.example.hackathon.domain.detox.dto; + +import com.example.hackathon.domain.mission.entity.MissionStatus; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +public record DetoxProgressResponse( + Long missionLogId, + MissionStatus status, + boolean inProgress, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + LocalTime endTime, + long remainingSeconds, + String titleMessage, + String unlockMessage, + int overlappingMemberCount, + List overlappingMembers +) { +} diff --git a/src/main/java/com/example/hackathon/domain/detox/dto/OverlappingMemberResponse.java b/src/main/java/com/example/hackathon/domain/detox/dto/OverlappingMemberResponse.java new file mode 100644 index 0000000..6fc76fa --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/detox/dto/OverlappingMemberResponse.java @@ -0,0 +1,14 @@ +package com.example.hackathon.domain.detox.dto; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +public record OverlappingMemberResponse( + Long userId, + String nickname, + LocalTime detoxStartTime, + LocalTime detoxEndTime, + LocalDateTime overlapStartDateTime, + LocalDateTime overlapEndDateTime +) { +} diff --git a/src/main/java/com/example/hackathon/domain/detox/service/DetoxProgressService.java b/src/main/java/com/example/hackathon/domain/detox/service/DetoxProgressService.java new file mode 100644 index 0000000..fb688ba --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/detox/service/DetoxProgressService.java @@ -0,0 +1,172 @@ +package com.example.hackathon.domain.detox.service; + +import com.example.hackathon.domain.detox.dto.DetoxProgressResponse; +import com.example.hackathon.domain.detox.dto.OverlappingMemberResponse; +import com.example.hackathon.domain.mission.entity.MissionStatus; +import com.example.hackathon.domain.mission.entity.UserMissionLog; +import com.example.hackathon.domain.mission.repository.UserMissionLogRepository; +import com.example.hackathon.domain.team.repository.TeamMemberRepository; +import com.example.hackathon.domain.user.entity.User; +import com.example.hackathon.domain.user.repository.UserRepository; +import com.example.hackathon.global.exception.BusinessException; +import com.example.hackathon.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DetoxProgressService { + + private static final String PROGRESS_TITLE = "폰 내려놓을 시간이에요."; + private static final String BEFORE_START_TITLE = "디지털 디톡스 시작 전이에요."; + private static final String FINISHED_TITLE = "디지털 디톡스가 종료되었어요."; + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + private final UserRepository userRepository; + private final UserMissionLogRepository userMissionLogRepository; + private final TeamMemberRepository teamMemberRepository; + private final Clock clock; + + public DetoxProgressResponse getProgress(String deviceId) { + LocalDateTime now = LocalDateTime.now(clock); + User user = findUser(deviceId); + UserMissionLog missionLog = findCurrentMissionLog(user, now.toLocalDate()); + validateMissionSuccess(missionLog); + + DetoxPeriod userPeriod = DetoxPeriod.of( + missionLog.getTargetDate(), user.getDetoxStartTime(), user.getDetoxEndTime()); + boolean inProgress = userPeriod.contains(now); + long remainingSeconds = calculateRemainingSeconds(userPeriod, now, inProgress); + List members = inProgress + ? findOverlappingMembers(user, missionLog.getTargetDate(), userPeriod) + : List.of(); + + String titleMessage = resolveTitleMessage(userPeriod, now, inProgress); + String unlockMessage = inProgress + ? "%s에 해제됩니다.".formatted(user.getDetoxEndTime().format(TIME_FORMATTER)) + : null; + + return new DetoxProgressResponse( + missionLog.getId(), missionLog.getStatus(), inProgress, + userPeriod.start(), userPeriod.end(), user.getDetoxEndTime(), remainingSeconds, + titleMessage, unlockMessage, members.size(), members + ); + } + + private User findUser(String deviceId) { + User user = userRepository.findByDeviceId(deviceId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_ERROR_404_NOT_FOUND)); + if (user.getDetoxStartTime() == null || user.getDetoxEndTime() == null) { + throw new BusinessException(ErrorCode.MISSION_ERROR_400_DETOX_TIME_NOT_SET); + } + return user; + } + + private UserMissionLog findCurrentMissionLog(User user, LocalDate today) { + return userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), today) + .orElseGet(() -> { + if (crossesMidnight(user.getDetoxStartTime(), user.getDetoxEndTime())) { + return userMissionLogRepository + .findByUserIdAndTargetDate(user.getId(), today.minusDays(1)) + .orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND)); + } + throw new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND); + }); + } + + private void validateMissionSuccess(UserMissionLog missionLog) { + if (missionLog.getStatus() != MissionStatus.SUCCESS) { + throw new BusinessException(ErrorCode.MISSION_ERROR_400_NOT_CERTIFIED); + } + } + + private long calculateRemainingSeconds(DetoxPeriod period, LocalDateTime now, boolean inProgress) { + return inProgress ? Math.max(Duration.between(now, period.end()).getSeconds(), 0) : 0; + } + + private List findOverlappingMembers( + User user, + LocalDate targetDate, + DetoxPeriod userPeriod + ) { + Set seenUserIds = new HashSet<>(); + return teamMemberRepository.findDistinctTeammatesByUserId(user.getId()).stream() + .filter(member -> !member.getId().equals(user.getId())) + .filter(member -> seenUserIds.add(member.getId())) + .filter(member -> member.getDetoxStartTime() != null && member.getDetoxEndTime() != null) + .map(member -> toOverlappingMember(member, targetDate, userPeriod)) + .filter(java.util.Objects::nonNull) + .sorted(Comparator.comparing(OverlappingMemberResponse::nickname) + .thenComparing(OverlappingMemberResponse::userId)) + .toList(); + } + + private OverlappingMemberResponse toOverlappingMember( + User member, + LocalDate targetDate, + DetoxPeriod userPeriod + ) { + // 모든 팀원의 시작 시각을 현재 미션의 targetDate에 결합하는 정책을 사용한다. + DetoxPeriod memberPeriod = DetoxPeriod.of( + targetDate, member.getDetoxStartTime(), member.getDetoxEndTime()); + if (!userPeriod.overlaps(memberPeriod)) { + return null; + } + DetoxPeriod overlap = userPeriod.intersection(memberPeriod); + return new OverlappingMemberResponse( + member.getId(), member.getNickname(), + member.getDetoxStartTime(), member.getDetoxEndTime(), + overlap.start(), overlap.end() + ); + } + + private String resolveTitleMessage(DetoxPeriod period, LocalDateTime now, boolean inProgress) { + if (inProgress) { + return PROGRESS_TITLE; + } + return now.isBefore(period.start()) ? BEFORE_START_TITLE : FINISHED_TITLE; + } + + private static boolean crossesMidnight(LocalTime startTime, LocalTime endTime) { + return !endTime.isAfter(startTime); + } + + private record DetoxPeriod(LocalDateTime start, LocalDateTime end) { + + private static DetoxPeriod of(LocalDate targetDate, LocalTime startTime, LocalTime endTime) { + LocalDate endDate = crossesMidnight(startTime, endTime) ? targetDate.plusDays(1) : targetDate; + return new DetoxPeriod( + LocalDateTime.of(targetDate, startTime), + LocalDateTime.of(endDate, endTime) + ); + } + + private boolean contains(LocalDateTime dateTime) { + return !dateTime.isBefore(start) && dateTime.isBefore(end); + } + + private boolean overlaps(DetoxPeriod other) { + return start.isBefore(other.end) && other.start.isBefore(end); + } + + private DetoxPeriod intersection(DetoxPeriod other) { + return new DetoxPeriod( + start.isAfter(other.start) ? start : other.start, + end.isBefore(other.end) ? end : other.end + ); + } + } +} diff --git a/src/main/java/com/example/hackathon/domain/image/config/S3Config.java b/src/main/java/com/example/hackathon/domain/image/config/S3Config.java index b523066..9864e93 100644 --- a/src/main/java/com/example/hackathon/domain/image/config/S3Config.java +++ b/src/main/java/com/example/hackathon/domain/image/config/S3Config.java @@ -27,32 +27,26 @@ public S3Config(S3Properties properties) { * access-key/secret-key 가 주입되면 그 자격증명을 쓰고, 비어 있으면 * 기본 자격증명 체인(환경변수·EC2 IAM 역할 등)에 위임한다. */ - // region 이 비면 서울로 폴백한다. 설정 하나 빠졌다고 앱 전체(와 모든 테스트)가 - // 부팅에 실패하는 것을 막는다. presign 은 어차피 실제 발급 시점에만 자격증명을 검증한다. - private static final String DEFAULT_REGION = "ap-northeast-2"; - @Bean public S3Presigner s3Presigner() { - String region = (properties.region() == null || properties.region().isBlank()) - ? DEFAULT_REGION - : properties.region(); return S3Presigner.builder() - .region(Region.of(region)) + .region(resolveRegion()) .credentialsProvider(credentialsProvider()) .build(); } @Bean public S3Client s3Client() { - String region = (properties.region() == null || properties.region().isBlank()) - ? DEFAULT_REGION - : properties.region(); return S3Client.builder() - .region(Region.of(region)) + .region(resolveRegion()) .credentialsProvider(credentialsProvider()) .build(); } + private Region resolveRegion() { + return Region.of(properties.resolvedRegion()); + } + private AwsCredentialsProvider credentialsProvider() { S3Properties.S3 s3 = properties.s3(); if (s3.accessKey() != null && !s3.accessKey().isBlank() diff --git a/src/main/java/com/example/hackathon/domain/image/config/S3Properties.java b/src/main/java/com/example/hackathon/domain/image/config/S3Properties.java index bf74044..423c201 100644 --- a/src/main/java/com/example/hackathon/domain/image/config/S3Properties.java +++ b/src/main/java/com/example/hackathon/domain/image/config/S3Properties.java @@ -11,6 +11,12 @@ public record S3Properties( String region, S3 s3 ) { + private static final String DEFAULT_REGION = "ap-northeast-2"; + + public String resolvedRegion() { + return region == null || region.isBlank() ? DEFAULT_REGION : region; + } + public record S3( String bucket, String accessKey, diff --git a/src/main/java/com/example/hackathon/domain/image/service/S3StorageService.java b/src/main/java/com/example/hackathon/domain/image/service/S3StorageService.java index ab5b273..3613f85 100644 --- a/src/main/java/com/example/hackathon/domain/image/service/S3StorageService.java +++ b/src/main/java/com/example/hackathon/domain/image/service/S3StorageService.java @@ -44,9 +44,9 @@ public String uploadMissionImage(Long missionLogId, MultipartFile image) { .build(), RequestBody.fromBytes(image.getBytes()) ); - return "https://%s.s3.%s.amazonaws.com/%s".formatted(bucket, properties.region(), key); + return "https://%s.s3.%s.amazonaws.com/%s".formatted(bucket, properties.resolvedRegion(), key); } catch (IOException | RuntimeException exception) { - throw new BusinessException(ErrorCode.IMAGE_ERROR_500_UPLOAD_FAILED); + throw new BusinessException(ErrorCode.IMAGE_ERROR_500_UPLOAD_FAILED, exception); } } @@ -67,7 +67,11 @@ private String validate(MultipartFile image) { if (image.getSize() > MAX_IMAGE_SIZE) { throw new BusinessException(ErrorCode.IMAGE_ERROR_400_SIZE_EXCEEDED); } - String extension = ALLOWED_CONTENT_TYPES.get(image.getContentType()); + String contentType = image.getContentType(); + if (contentType == null) { + throw new BusinessException(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); + } + String extension = ALLOWED_CONTENT_TYPES.get(contentType); if (extension == null) { throw new BusinessException(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); } diff --git a/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationService.java b/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationService.java index 8032671..f152717 100644 --- a/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationService.java +++ b/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationService.java @@ -32,19 +32,19 @@ public class MissionCertificationService { private final UserMissionLogRepository userMissionLogRepository; private final StorageService storageService; private final Clock clock; + private final MissionCertificationTransactionService transactionService; - @Transactional(noRollbackFor = BusinessException.class) public MissionCertificationResponse certify(String deviceId, MultipartFile image) { LocalDateTime now = LocalDateTime.now(clock); User user = findUser(deviceId); - UserMissionLog log = findTodayLogForUpdate(user, now); + LocalDate targetDate = MissionTargetDateResolver.resolve(user, now); + UserMissionLog log = userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), targetDate) + .orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND)); validateInitialCertification(log, now); String newImageUrl = storageService.uploadMissionImage(log.getId(), image); - deleteOnRollback(newImageUrl, log.getId()); try { - log.certify(newImageUrl, now); - userMissionLogRepository.saveAndFlush(log); + log = transactionService.certifyUploadedImage(user.getId(), targetDate, newImageUrl, now); } catch (RuntimeException exception) { deleteQuietly(newImageUrl, log.getId()); throw exception; @@ -77,7 +77,7 @@ public MissionCertificationRetakeResponse retake(String deviceId, MultipartFile deleteAfterCommit(oldImageUrl, log.getId()); return MissionCertificationRetakeResponse.of( - log, now, user.getDetoxEndTime(), now.isBefore(detoxEnd)); + log, log.getUpdatedAt(), user.getDetoxEndTime(), now.isBefore(detoxEnd)); } private User findUser(String deviceId) { @@ -90,7 +90,7 @@ private User findUser(String deviceId) { } private UserMissionLog findTodayLogForUpdate(User user, LocalDateTime now) { - LocalDate targetDate = resolveTargetDate(user, now); + LocalDate targetDate = MissionTargetDateResolver.resolve(user, now); return userMissionLogRepository.findByUserIdAndTargetDateForUpdate(user.getId(), targetDate) .orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND)); } @@ -125,15 +125,6 @@ private void validateRetake(UserMissionLog log, LocalDateTime now, LocalDateTime } } - private LocalDate resolveTargetDate(User user, LocalDateTime now) { - LocalTime startTime = user.getDetoxStartTime(); - LocalTime endTime = user.getDetoxEndTime(); - if (!endTime.isAfter(startTime) && now.toLocalTime().isBefore(endTime)) { - return now.toLocalDate().minusDays(1); - } - return now.toLocalDate(); - } - private LocalDateTime resolveDetoxEndDateTime( LocalDate targetDate, LocalTime startTime, diff --git a/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationTransactionService.java b/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationTransactionService.java new file mode 100644 index 0000000..9106091 --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/mission/service/MissionCertificationTransactionService.java @@ -0,0 +1,53 @@ +package com.example.hackathon.domain.mission.service; + +import com.example.hackathon.domain.mission.entity.MissionStatus; +import com.example.hackathon.domain.mission.entity.UserMissionLog; +import com.example.hackathon.domain.mission.repository.UserMissionLogRepository; +import com.example.hackathon.global.exception.BusinessException; +import com.example.hackathon.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class MissionCertificationTransactionService { + + private final UserMissionLogRepository userMissionLogRepository; + + @Transactional(noRollbackFor = BusinessException.class) + public UserMissionLog certifyUploadedImage( + Long userId, + LocalDate targetDate, + String imageUrl, + LocalDateTime now + ) { + UserMissionLog log = userMissionLogRepository + .findByUserIdAndTargetDateForUpdate(userId, targetDate) + .orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND)); + validateInitialCertification(log, now); + log.certify(imageUrl, now); + return userMissionLogRepository.saveAndFlush(log); + } + + private void validateInitialCertification(UserMissionLog log, LocalDateTime now) { + if (now.isBefore(log.getAssignedAt())) { + throw new BusinessException(ErrorCode.MISSION_ERROR_400_BEFORE_DETOX_START); + } + if (now.isAfter(log.getDeadlineAt()) + && (log.getStatus() == MissionStatus.ASSIGNED || log.getStatus() == MissionStatus.CONFIRMED)) { + log.updateStatus(MissionStatus.FAILED); + userMissionLogRepository.saveAndFlush(log); + throw new BusinessException(ErrorCode.MISSION_ERROR_400_DEADLINE_EXCEEDED); + } + if (log.getStatus() == MissionStatus.FAILED) { + throw new BusinessException(ErrorCode.MISSION_ERROR_400_ALREADY_FAILED); + } + if (log.getStatus() == MissionStatus.SUCCESS) { + throw new BusinessException(ErrorCode.MISSION_ERROR_400_ALREADY_CERTIFIED); + } + } +} diff --git a/src/main/java/com/example/hackathon/domain/mission/service/MissionService.java b/src/main/java/com/example/hackathon/domain/mission/service/MissionService.java index 7ab4f12..5706583 100644 --- a/src/main/java/com/example/hackathon/domain/mission/service/MissionService.java +++ b/src/main/java/com/example/hackathon/domain/mission/service/MissionService.java @@ -25,7 +25,6 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -35,8 +34,6 @@ @Transactional(readOnly = true) public class MissionService { - private static final long MISSION_DEADLINE_MINUTES = 10; - private final UserRepository userRepository; private final MissionRepository missionRepository; private final DailyMissionRepository dailyMissionRepository; @@ -46,35 +43,34 @@ public class MissionService { @Transactional public MissionTodayResponse getOrCreateTodayMission(String deviceId) { - return getOrCreateTodayMission(deviceId, LocalDateTime.now(clock)); + return MissionTodayResponse.from(getOrCreateTodayMissionLog(deviceId, LocalDateTime.now(clock))); } - private MissionTodayResponse getOrCreateTodayMission(String deviceId, LocalDateTime nowTime) { - // 1. 사용자 조회 + private UserMissionLog getOrCreateTodayMissionLog(String deviceId, LocalDateTime nowTime) { User user = userRepository.findByDeviceId(deviceId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_ERROR_404_NOT_FOUND)); - - // 2. 디톡스 설정 시간 확인 if (user.getDetoxStartTime() == null || user.getDetoxEndTime() == null) { throw new BusinessException(ErrorCode.MISSION_ERROR_400_DETOX_TIME_NOT_SET); } + LocalDate targetDate = MissionTargetDateResolver.resolve(user, nowTime); + return getOrCreateTodayMissionLog(user, targetDate, nowTime); + } - // 자정 넘김 고려한 targetDate 계산 - LocalDate targetDate = calculateTargetDate(user, nowTime); - - // 3. 디톡스 시작 시각 이전인지 확인 + private UserMissionLog getOrCreateTodayMissionLog( + User user, + LocalDate targetDate, + LocalDateTime nowTime + ) { LocalDateTime detoxStartDateTime = LocalDateTime.of(targetDate, user.getDetoxStartTime()); if (nowTime.isBefore(detoxStartDateTime)) { throw new BusinessException(ErrorCode.MISSION_ERROR_400_BEFORE_DETOX_START); } - // 4. 오늘 날짜의 DailyMission 조회 및 생성 (없을 시 생성) DailyMission dailyMission = getOrCreateDailyMission(targetDate); - - // 5. 오늘 날짜의 USER_MISSION_LOG 조회 및 생성 UserMissionLog log = userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), targetDate) .orElseGet(() -> { - LocalDateTime deadlineDateTime = detoxStartDateTime.plusMinutes(MISSION_DEADLINE_MINUTES); + LocalDateTime deadlineDateTime = detoxStartDateTime + .plusMinutes(MissionTargetDateResolver.DEADLINE_MINUTES); UserMissionLog newLog = UserMissionLog.builder() .user(user) @@ -91,10 +87,7 @@ private MissionTodayResponse getOrCreateTodayMission(String deviceId, LocalDateT ); }); - // Lazy 실패 만료 처리 적용 - log = checkAndExpireLog(log, nowTime); - - return MissionTodayResponse.from(log); + return checkAndExpireLog(log, nowTime); } @Transactional @@ -232,19 +225,6 @@ private T saveOrFetchExisting(Supplier saveOperation, Supplier { - MissionTodayResponse todayResponse = getOrCreateTodayMission(deviceId, now); - return userMissionLogRepository.findById(todayResponse.missionLogId()) - .orElseThrow(() -> new BusinessException(ErrorCode.MISSION_ERROR_404_NOT_FOUND)); - }); + .orElseGet(() -> getOrCreateTodayMissionLog(user, targetDate, now)); return checkAndExpireLog(log, now); } diff --git a/src/main/java/com/example/hackathon/domain/mission/service/MissionTargetDateResolver.java b/src/main/java/com/example/hackathon/domain/mission/service/MissionTargetDateResolver.java new file mode 100644 index 0000000..efc674b --- /dev/null +++ b/src/main/java/com/example/hackathon/domain/mission/service/MissionTargetDateResolver.java @@ -0,0 +1,22 @@ +package com.example.hackathon.domain.mission.service; + +import com.example.hackathon.domain.user.entity.User; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +final class MissionTargetDateResolver { + + static final long DEADLINE_MINUTES = 10; + + private MissionTargetDateResolver() { + } + + static LocalDate resolve(User user, LocalDateTime now) { + LocalDate today = now.toLocalDate(); + LocalDateTime yesterdayDeadline = LocalDateTime + .of(today.minusDays(1), user.getDetoxStartTime()) + .plusMinutes(DEADLINE_MINUTES); + return now.isBefore(yesterdayDeadline) ? today.minusDays(1) : today; + } +} diff --git a/src/main/java/com/example/hackathon/domain/team/repository/TeamMemberRepository.java b/src/main/java/com/example/hackathon/domain/team/repository/TeamMemberRepository.java index 922c23f..0f25dbb 100644 --- a/src/main/java/com/example/hackathon/domain/team/repository/TeamMemberRepository.java +++ b/src/main/java/com/example/hackathon/domain/team/repository/TeamMemberRepository.java @@ -4,6 +4,8 @@ import com.example.hackathon.domain.team.entity.TeamMember; import com.example.hackathon.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -18,4 +20,9 @@ public interface TeamMemberRepository extends JpaRepository { /** 팀의 구성원 목록 (팀 상세용). */ List findByTeam(Team team); + + @Query("select distinct tm.user from TeamMember tm " + + "where tm.team.id in (select mine.team.id from TeamMember mine where mine.user.id = :userId) " + + "and tm.user.id <> :userId") + List findDistinctTeammatesByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/example/hackathon/global/exception/BusinessException.java b/src/main/java/com/example/hackathon/global/exception/BusinessException.java index 1d13167..b676581 100644 --- a/src/main/java/com/example/hackathon/global/exception/BusinessException.java +++ b/src/main/java/com/example/hackathon/global/exception/BusinessException.java @@ -23,4 +23,9 @@ public BusinessException(ErrorCode errorCode, String message) { super(message); this.errorCode = errorCode; } + + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } } diff --git a/src/main/java/com/example/hackathon/global/exception/ErrorCode.java b/src/main/java/com/example/hackathon/global/exception/ErrorCode.java index 00e143b..f76b8b7 100644 --- a/src/main/java/com/example/hackathon/global/exception/ErrorCode.java +++ b/src/main/java/com/example/hackathon/global/exception/ErrorCode.java @@ -47,6 +47,7 @@ public enum ErrorCode { MISSION_ERROR_400_ALREADY_CERTIFIED(HttpStatus.BAD_REQUEST, "이미 인증된 미션입니다. 사진 재등록 API를 이용해주세요."), MISSION_ERROR_400_CERTIFICATION_NOT_FOUND(HttpStatus.BAD_REQUEST, "재등록할 인증 사진이 없습니다."), MISSION_ERROR_400_DETOX_ALREADY_ENDED(HttpStatus.BAD_REQUEST, "디톡스 종료 후에는 인증 사진을 변경할 수 없습니다."), + MISSION_ERROR_400_NOT_CERTIFIED(HttpStatus.BAD_REQUEST, "미션 인증 완료 후 디톡스 진행 화면을 조회할 수 있습니다."), MISSION_ERROR_400_INVALID_TRANSITION(HttpStatus.BAD_REQUEST, "잘못된 상태 전이입니다."); private final HttpStatus status; diff --git a/src/test/java/com/example/hackathon/domain/detox/service/DetoxProgressServiceTest.java b/src/test/java/com/example/hackathon/domain/detox/service/DetoxProgressServiceTest.java new file mode 100644 index 0000000..fecab5e --- /dev/null +++ b/src/test/java/com/example/hackathon/domain/detox/service/DetoxProgressServiceTest.java @@ -0,0 +1,229 @@ +package com.example.hackathon.domain.detox.service; + +import com.example.hackathon.domain.detox.dto.DetoxProgressResponse; +import com.example.hackathon.domain.mission.entity.MissionStatus; +import com.example.hackathon.domain.mission.entity.UserMissionLog; +import com.example.hackathon.domain.mission.repository.UserMissionLogRepository; +import com.example.hackathon.domain.team.repository.TeamMemberRepository; +import com.example.hackathon.domain.user.entity.User; +import com.example.hackathon.domain.user.repository.UserRepository; +import com.example.hackathon.global.exception.BusinessException; +import com.example.hackathon.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DetoxProgressServiceTest { + + private static final String DEVICE_ID = "detox-progress-device"; + private static final LocalDate TARGET_DATE = LocalDate.of(2026, 7, 11); + + @Mock UserRepository userRepository; + @Mock UserMissionLogRepository userMissionLogRepository; + @Mock TeamMemberRepository teamMemberRepository; + @Mock Clock clock; + @InjectMocks DetoxProgressService service; + + private User user; + private UserMissionLog missionLog; + + @BeforeEach + void setUp() { + user = user(1L, "사용자", LocalTime.of(22, 0), LocalTime.of(23, 0)); + missionLog = missionLog(MissionStatus.SUCCESS, TARGET_DATE); + } + + @Test + void successMissionReturnsProgressAtStartBoundary() { + prepare(TARGET_DATE.atTime(22, 0), TARGET_DATE, missionLog); + when(teamMemberRepository.findDistinctTeammatesByUserId(user.getId())).thenReturn(List.of()); + + DetoxProgressResponse response = service.getProgress(DEVICE_ID); + + assertThat(response.inProgress()).isTrue(); + assertThat(response.remainingSeconds()).isEqualTo(3600); + assertThat(response.unlockMessage()).isEqualTo("23:00에 해제됩니다."); + assertThat(response.overlappingMembers()).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = MissionStatus.class, names = {"ASSIGNED", "CONFIRMED", "FAILED"}) + void nonSuccessMissionIsRejected(MissionStatus status) { + ReflectionTestUtils.setField(missionLog, "status", status); + prepare(TARGET_DATE.atTime(22, 10), TARGET_DATE, missionLog); + + assertThatThrownBy(() -> service.getProgress(DEVICE_ID)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorCode()) + .isEqualTo(ErrorCode.MISSION_ERROR_400_NOT_CERTIFIED); + } + + @Test + void missingMissionLogIsRejected() { + setClock(TARGET_DATE.atTime(22, 10)); + when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(user)); + when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), TARGET_DATE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getProgress(DEVICE_ID)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorCode()) + .isEqualTo(ErrorCode.MISSION_ERROR_404_NOT_FOUND); + } + + @Test + void detoxTimeNotConfiguredIsRejected() { + User userWithoutDetoxTime = user(5L, "미설정", null, null); + setClock(TARGET_DATE.atTime(22, 10)); + when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(userWithoutDetoxTime)); + + assertThatThrownBy(() -> service.getProgress(DEVICE_ID)) + .isInstanceOf(BusinessException.class) + .hasMessage(ErrorCode.MISSION_ERROR_400_DETOX_TIME_NOT_SET.getMessage()) + .extracting(exception -> ((BusinessException) exception).getErrorCode()) + .isEqualTo(ErrorCode.MISSION_ERROR_400_DETOX_TIME_NOT_SET); + verifyNoInteractions(userMissionLogRepository, teamMemberRepository); + } + + @Test + void beforeStartAndAtEndAreNotInProgress() { + prepare(TARGET_DATE.atTime(21, 59), TARGET_DATE, missionLog); + DetoxProgressResponse beforeStart = service.getProgress(DEVICE_ID); + assertThat(beforeStart.inProgress()).isFalse(); + assertThat(beforeStart.remainingSeconds()).isZero(); + + reset(clock, userRepository, userMissionLogRepository); + prepare(TARGET_DATE.atTime(23, 0), TARGET_DATE, missionLog); + DetoxProgressResponse atEnd = service.getProgress(DEVICE_ID); + assertThat(atEnd.inProgress()).isFalse(); + assertThat(atEnd.remainingSeconds()).isZero(); + verifyNoInteractions(teamMemberRepository); + } + + @Test + void overlappingMembersAreFilteredDeduplicatedAndSorted() { + prepare(TARGET_DATE.atTime(22, 15), TARGET_DATE, missionLog); + User laterName = user(2L, "하늘", LocalTime.of(22, 30), LocalTime.of(23, 30)); + User firstName = user(3L, "가람", LocalTime.of(21, 30), LocalTime.of(22, 30)); + User touchingBoundary = user(4L, "경계", LocalTime.of(23, 0), LocalTime.of(23, 30)); + when(teamMemberRepository.findDistinctTeammatesByUserId(user.getId())) + .thenReturn(List.of(laterName, firstName, laterName, touchingBoundary, user)); + + DetoxProgressResponse response = service.getProgress(DEVICE_ID); + + assertThat(response.overlappingMemberCount()).isEqualTo(2); + assertThat(response.overlappingMembers()) + .extracting(member -> member.nickname()) + .containsExactly("가람", "하늘"); + assertThat(response.overlappingMembers().get(0).overlapStartDateTime()) + .isEqualTo(TARGET_DATE.atTime(22, 0)); + assertThat(response.overlappingMembers().get(0).overlapEndDateTime()) + .isEqualTo(TARGET_DATE.atTime(22, 30)); + } + + @Test + void allJoinedTeamsAreQueriedThroughSingleRepositoryMethod() { + prepare(TARGET_DATE.atTime(22, 15), TARGET_DATE, missionLog); + when(teamMemberRepository.findDistinctTeammatesByUserId(user.getId())).thenReturn(List.of()); + + service.getProgress(DEVICE_ID); + + verify(teamMemberRepository, times(1)).findDistinctTeammatesByUserId(user.getId()); + verifyNoMoreInteractions(teamMemberRepository); + } + + @Test + void overnightPeriodUsesNextDayAsEnd() { + user = user(1L, "야간", LocalTime.of(23, 0), LocalTime.of(1, 0)); + missionLog = missionLog(MissionStatus.SUCCESS, TARGET_DATE); + LocalDateTime now = TARGET_DATE.plusDays(1).atTime(0, 30); + setClock(now); + when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(user)); + when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), now.toLocalDate())) + .thenReturn(Optional.empty()); + when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), TARGET_DATE)) + .thenReturn(Optional.of(missionLog)); + User sameTargetDateEarlyMorning = user(2L, "새벽", LocalTime.of(0, 30), LocalTime.of(2, 0)); + User overlappingNight = user(3L, "심야", LocalTime.of(23, 30), LocalTime.of(0, 45)); + when(teamMemberRepository.findDistinctTeammatesByUserId(user.getId())) + .thenReturn(List.of(sameTargetDateEarlyMorning, overlappingNight)); + + DetoxProgressResponse response = service.getProgress(DEVICE_ID); + + assertThat(response.inProgress()).isTrue(); + assertThat(response.endDateTime()).isEqualTo(TARGET_DATE.plusDays(1).atTime(1, 0)); + assertThat(response.remainingSeconds()).isEqualTo(1800); + assertThat(response.overlappingMembers()) + .extracting(member -> member.nickname()) + .containsExactly("심야"); + } + + @Test + void teammatesAreEmptyAfterDetoxEnds() { + prepare(TARGET_DATE.atTime(23, 1), TARGET_DATE, missionLog); + + DetoxProgressResponse response = service.getProgress(DEVICE_ID); + + assertThat(response.inProgress()).isFalse(); + assertThat(response.overlappingMemberCount()).isZero(); + assertThat(response.overlappingMembers()).isEmpty(); + verifyNoInteractions(teamMemberRepository); + } + + private void prepare(LocalDateTime now, LocalDate targetDate, UserMissionLog log) { + setClock(now); + when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(user)); + when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), targetDate)) + .thenReturn(Optional.of(log)); + } + + private void setClock(LocalDateTime now) { + ZoneId zone = ZoneId.of("Asia/Seoul"); + Instant instant = now.atZone(zone).toInstant(); + when(clock.instant()).thenReturn(instant); + when(clock.getZone()).thenReturn(zone); + } + + private User user(Long id, String nickname, LocalTime startTime, LocalTime endTime) { + User result = User.builder() + .deviceId("device-" + id) + .nickname(nickname) + .detoxStartTime(startTime) + .detoxEndTime(endTime) + .build(); + ReflectionTestUtils.setField(result, "id", id); + return result; + } + + private UserMissionLog missionLog(MissionStatus status, LocalDate targetDate) { + UserMissionLog result = UserMissionLog.builder() + .user(user) + .targetDate(targetDate) + .status(status) + .assignedAt(targetDate.atTime(user.getDetoxStartTime())) + .deadlineAt(targetDate.atTime(user.getDetoxStartTime()).plusMinutes(10)) + .build(); + ReflectionTestUtils.setField(result, "id", 100L); + return result; + } +} diff --git a/src/test/java/com/example/hackathon/domain/image/service/S3StorageServiceTest.java b/src/test/java/com/example/hackathon/domain/image/service/S3StorageServiceTest.java index d39a563..371a196 100644 --- a/src/test/java/com/example/hackathon/domain/image/service/S3StorageServiceTest.java +++ b/src/test/java/com/example/hackathon/domain/image/service/S3StorageServiceTest.java @@ -10,9 +10,15 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import org.mockito.ArgumentCaptor; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class S3StorageServiceTest { @@ -45,6 +51,13 @@ void nonImageContentTypeIsRejected() { assertError(image, ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); } + @Test + void nullContentTypeIsRejected() { + MockMultipartFile image = new MockMultipartFile("image", "proof", null, new byte[]{1}); + + assertError(image, ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); + } + @Test void imageLargerThanTenMegabytesIsRejected() { MockMultipartFile image = new MockMultipartFile( @@ -53,6 +66,42 @@ void imageLargerThanTenMegabytesIsRejected() { assertError(image, ErrorCode.IMAGE_ERROR_400_SIZE_EXCEEDED); } + @Test + void uploadReturnsUrlWithConfiguredRegion() { + when(s3Client.putObject(any(PutObjectRequest.class), any(software.amazon.awssdk.core.sync.RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + MockMultipartFile image = new MockMultipartFile("image", "proof.jpg", "image/jpeg", new byte[]{1}); + + String imageUrl = storageService.uploadMissionImage(15L, image); + + assertThat(imageUrl).startsWith("https://test-bucket.s3.ap-northeast-2.amazonaws.com/mission/15/") + .endsWith(".jpg"); + } + + @Test + void uploadReturnsUrlWithDefaultRegionWhenRegionIsBlank() { + storageService = new S3StorageService(s3Client, new S3Properties( + " ", new S3Properties.S3("test-bucket", "access", "secret", 300))); + when(s3Client.putObject(any(PutObjectRequest.class), any(software.amazon.awssdk.core.sync.RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + MockMultipartFile image = new MockMultipartFile("image", "proof.png", "image/png", new byte[]{1}); + + String imageUrl = storageService.uploadMissionImage(15L, image); + + assertThat(imageUrl).startsWith("https://test-bucket.s3.ap-northeast-2.amazonaws.com/mission/15/") + .endsWith(".png"); + } + + @Test + void deleteExtractsObjectKeyFromImageUrl() { + storageService.delete("https://test-bucket.s3.ap-northeast-2.amazonaws.com/mission/15/photo.jpg"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(requestCaptor.capture()); + assertThat(requestCaptor.getValue().bucket()).isEqualTo("test-bucket"); + assertThat(requestCaptor.getValue().key()).isEqualTo("mission/15/photo.jpg"); + } + private void assertError(MockMultipartFile image, ErrorCode errorCode) { assertThatThrownBy(() -> storageService.uploadMissionImage(1L, image)) .isInstanceOf(BusinessException.class) diff --git a/src/test/java/com/example/hackathon/domain/mission/service/MissionCertificationServiceTest.java b/src/test/java/com/example/hackathon/domain/mission/service/MissionCertificationServiceTest.java index 4751570..55bc69b 100644 --- a/src/test/java/com/example/hackathon/domain/mission/service/MissionCertificationServiceTest.java +++ b/src/test/java/com/example/hackathon/domain/mission/service/MissionCertificationServiceTest.java @@ -47,6 +47,7 @@ class MissionCertificationServiceTest { @Mock UserMissionLogRepository userMissionLogRepository; @Mock StorageService storageService; @Mock Clock clock; + @Mock MissionCertificationTransactionService transactionService; @InjectMocks MissionCertificationService service; private User user; @@ -84,6 +85,11 @@ void certifyChangesEligibleStatusToSuccess(MissionStatus initialStatus) { LocalDateTime now = TARGET_DATE.atTime(22, 5); prepare(now, TARGET_DATE, log); when(storageService.uploadMissionImage(log.getId(), image)).thenReturn(IMAGE_URL); + when(transactionService.certifyUploadedImage(user.getId(), TARGET_DATE, IMAGE_URL, now)) + .thenAnswer(invocation -> { + log.certify(IMAGE_URL, now); + return log; + }); MissionCertificationResponse response = service.certify(DEVICE_ID, image); @@ -91,7 +97,7 @@ void certifyChangesEligibleStatusToSuccess(MissionStatus initialStatus) { assertThat(response.imageUrl()).isEqualTo(IMAGE_URL); assertThat(response.completedAt()).isEqualTo(now); assertThat(response.canRetake()).isTrue(); - verify(userMissionLogRepository).saveAndFlush(log); + verify(transactionService).certifyUploadedImage(user.getId(), TARGET_DATE, IMAGE_URL, now); } @Test @@ -137,15 +143,20 @@ void retakeReplacesOnlyImageAndKeepsCompletion() { ReflectionTestUtils.setField(log, "imageUrl", "https://bucket/old.jpg"); ReflectionTestUtils.setField(log, "completedAt", completedAt); LocalDateTime now = TARGET_DATE.atTime(22, 20); + LocalDateTime storedUpdatedAt = now.plusSeconds(1); prepare(now, TARGET_DATE, log); when(storageService.uploadMissionImage(log.getId(), image)).thenReturn(IMAGE_URL); + when(userMissionLogRepository.saveAndFlush(log)).thenAnswer(invocation -> { + ReflectionTestUtils.setField(log, "updatedAt", storedUpdatedAt); + return log; + }); MissionCertificationRetakeResponse response = service.retake(DEVICE_ID, image); assertThat(response.status()).isEqualTo(MissionStatus.SUCCESS); assertThat(response.imageUrl()).isEqualTo(IMAGE_URL); assertThat(response.completedAt()).isEqualTo(completedAt); - assertThat(response.updatedAt()).isEqualTo(now); + assertThat(response.updatedAt()).isEqualTo(storedUpdatedAt); verify(storageService).delete("https://bucket/old.jpg"); } @@ -162,18 +173,50 @@ void retakeAfterDetoxEndIsRejected() { } @Test - void overnightRetakeUsesPreviousTargetDateUntilDetoxEnd() { + void retakeUploadFailureKeepsExistingCertification() { + LocalDateTime completedAt = TARGET_DATE.atTime(22, 4); + String oldImageUrl = "https://bucket/old.jpg"; + ReflectionTestUtils.setField(log, "status", MissionStatus.SUCCESS); + ReflectionTestUtils.setField(log, "imageUrl", oldImageUrl); + ReflectionTestUtils.setField(log, "completedAt", completedAt); + prepare(TARGET_DATE.atTime(22, 20), TARGET_DATE, log); + when(storageService.uploadMissionImage(log.getId(), image)).thenThrow(new RuntimeException("S3 failure")); + + assertThatThrownBy(() -> service.retake(DEVICE_ID, image)).isInstanceOf(RuntimeException.class); + assertThat(log.getImageUrl()).isEqualTo(oldImageUrl); + assertThat(log.getCompletedAt()).isEqualTo(completedAt); + assertThat(log.getStatus()).isEqualTo(MissionStatus.SUCCESS); + verify(userMissionLogRepository, never()).saveAndFlush(any()); + verify(storageService, never()).delete(anyString()); + } + + @Test + void retakeSaveFailureDeletesNewlyUploadedImage() { + ReflectionTestUtils.setField(log, "status", MissionStatus.SUCCESS); + ReflectionTestUtils.setField(log, "imageUrl", "https://bucket/old.jpg"); + ReflectionTestUtils.setField(log, "completedAt", TARGET_DATE.atTime(22, 4)); + prepare(TARGET_DATE.atTime(22, 20), TARGET_DATE, log); + when(storageService.uploadMissionImage(log.getId(), image)).thenReturn(IMAGE_URL); + when(userMissionLogRepository.saveAndFlush(log)).thenThrow(new RuntimeException("DB failure")); + + assertThatThrownBy(() -> service.retake(DEVICE_ID, image)).isInstanceOf(RuntimeException.class); + verify(storageService).delete(IMAGE_URL); + verify(storageService, never()).delete("https://bucket/old.jpg"); + } + + @Test + void overnightRetakeUsesPreviousTargetDateWithinMissionDeadline() { user = User.builder() .deviceId(DEVICE_ID) .nickname("야간") - .detoxStartTime(LocalTime.of(23, 0)) + .detoxStartTime(LocalTime.of(23, 55)) .detoxEndTime(LocalTime.of(1, 0)) .build(); ReflectionTestUtils.setField(user, "id", 2L); ReflectionTestUtils.setField(log, "user", user); ReflectionTestUtils.setField(log, "status", MissionStatus.SUCCESS); ReflectionTestUtils.setField(log, "imageUrl", "old.jpg"); - LocalDateTime now = TARGET_DATE.plusDays(1).atTime(0, 30); + LocalDateTime now = TARGET_DATE.plusDays(1).atTime(0, 4); prepare(now, TARGET_DATE, log); when(storageService.uploadMissionImage(log.getId(), image)).thenReturn(IMAGE_URL); @@ -187,7 +230,7 @@ void overnightRetakeUsesPreviousTargetDateUntilDetoxEnd() { void missingTodayMissionIsRejected() { setClock(TARGET_DATE.atTime(22, 5)); when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(user)); - when(userMissionLogRepository.findByUserIdAndTargetDateForUpdate(user.getId(), TARGET_DATE)) + when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), TARGET_DATE)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> service.certify(DEVICE_ID, image)) @@ -199,7 +242,9 @@ void missingTodayMissionIsRejected() { private void prepare(LocalDateTime now, LocalDate targetDate, UserMissionLog targetLog) { setClock(now); when(userRepository.findByDeviceId(DEVICE_ID)).thenReturn(Optional.of(user)); - when(userMissionLogRepository.findByUserIdAndTargetDateForUpdate(user.getId(), targetDate)) + lenient().when(userMissionLogRepository.findByUserIdAndTargetDate(user.getId(), targetDate)) + .thenReturn(Optional.of(targetLog)); + lenient().when(userMissionLogRepository.findByUserIdAndTargetDateForUpdate(user.getId(), targetDate)) .thenReturn(Optional.of(targetLog)); }