From 722b45e010588da5defb735195cf4826f058ebab Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Fri, 6 Feb 2026 10:40:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=84=A0=ED=98=B8=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 생성 시 1일 이상의 주기는 사용자 설정 시간에 맞춰 스케줄링되도록 로직 수정 --- .../member/controller/MemberController.java | 13 +++++ .../MemberNotificationTimeUpdateRequest.java | 12 +++++ .../response/MemberFindResponse.java | 5 +- .../recyclestudy/member/domain/Member.java | 10 +++- .../member/service/MemberService.java | 26 +++++++++- .../MemberNotificationTimeUpdateInput.java | 11 +++++ .../service/output/MemberFindOutput.java | 11 +++-- .../review/service/ReviewService.java | 17 +++++-- ...206_1__add_notification_time_to_member.sql | 1 + .../controller/MemberControllerTest.java | 43 +++++++++++++++++ .../member/service/MemberServiceTest.java | 48 ++++++++++++++++++- .../review/service/ReviewServiceTest.java | 43 ++++++++++++++++- 12 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/recyclestudy/member/controller/request/MemberNotificationTimeUpdateRequest.java create mode 100644 src/main/java/com/recyclestudy/member/service/input/MemberNotificationTimeUpdateInput.java create mode 100644 src/main/resources/db/migration/V20260206_1__add_notification_time_to_member.sql diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index f3dda0e..99e9229 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -2,12 +2,14 @@ import com.recyclestudy.common.annotation.AuthDevice; import com.recyclestudy.email.DeviceAuthEmailSender; +import com.recyclestudy.member.controller.request.MemberNotificationTimeUpdateRequest; import com.recyclestudy.member.controller.request.MemberSaveRequest; import com.recyclestudy.member.controller.response.MemberFindResponse; import com.recyclestudy.member.controller.response.MemberSaveResponse; import com.recyclestudy.member.domain.DeviceIdentifier; import com.recyclestudy.member.service.MemberService; import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberNotificationTimeUpdateInput; import com.recyclestudy.member.service.input.MemberSaveInput; import com.recyclestudy.member.service.output.MemberFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; @@ -15,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -51,4 +54,14 @@ public ResponseEntity findAllMemberDevices( final MemberFindResponse response = MemberFindResponse.from(output); return ResponseEntity.ok(response); } + + @PatchMapping("/notification-time") + public ResponseEntity updateNotificationTime( + @RequestBody final MemberNotificationTimeUpdateRequest request, + @AuthDevice final DeviceIdentifier identifier + ) { + final MemberNotificationTimeUpdateInput input = request.toInput(identifier); + memberService.updateNotificationTime(input); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/recyclestudy/member/controller/request/MemberNotificationTimeUpdateRequest.java b/src/main/java/com/recyclestudy/member/controller/request/MemberNotificationTimeUpdateRequest.java new file mode 100644 index 0000000..0a35c33 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/request/MemberNotificationTimeUpdateRequest.java @@ -0,0 +1,12 @@ +package com.recyclestudy.member.controller.request; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.service.input.MemberNotificationTimeUpdateInput; +import java.time.LocalTime; + +public record MemberNotificationTimeUpdateRequest(LocalTime notificationTime) { + + public MemberNotificationTimeUpdateInput toInput(final DeviceIdentifier identifier) { + return MemberNotificationTimeUpdateInput.of(identifier, notificationTime); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java index 4845dce..219d325 100644 --- a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java @@ -2,16 +2,17 @@ import com.recyclestudy.member.service.output.MemberFindOutput; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; -public record MemberFindResponse(String email, List devices) { +public record MemberFindResponse(String email, LocalTime notificationTime, List devices) { public static MemberFindResponse from(final MemberFindOutput output) { final List memberFindElements = output.elements().stream() .map(outputElement -> new MemberFindElement(outputElement.identifier().getValue(), outputElement.createdAt())) .toList(); - return new MemberFindResponse(output.email().getValue(), memberFindElements); + return new MemberFindResponse(output.email().getValue(), output.notificationTime(), memberFindElements); } private record MemberFindElement(String identifier, LocalDateTime createdAt) { diff --git a/src/main/java/com/recyclestudy/member/domain/Member.java b/src/main/java/com/recyclestudy/member/domain/Member.java index b994ca1..97f2558 100644 --- a/src/main/java/com/recyclestudy/member/domain/Member.java +++ b/src/main/java/com/recyclestudy/member/domain/Member.java @@ -7,6 +7,7 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import java.time.LocalTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -25,9 +26,12 @@ public class Member extends BaseEntity { @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) private Email email; + @Column(name = "notification_time") + private LocalTime notificationTime; + public static Member withoutId(final Email email) { validateNotNull(email); - return new Member(email); + return new Member(email, null); } private static void validateNotNull(final Email email) { @@ -39,4 +43,8 @@ private static void validateNotNull(final Email email) { public boolean hasEmail(final Email email) { return this.email.equals(email); } + + public void updateNotificationTime(final LocalTime notificationTime) { + this.notificationTime = notificationTime; + } } diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index 3d30219..b0638c1 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -2,6 +2,7 @@ import com.recyclestudy.exception.BadRequestException; import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.ActivationExpiredDateTime; import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.domain.DeviceIdentifier; @@ -11,15 +12,18 @@ import com.recyclestudy.member.repository.MemberRepository; import com.recyclestudy.member.service.input.DeviceDeleteInput; import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberNotificationTimeUpdateInput; import com.recyclestudy.member.service.input.MemberSaveInput; import com.recyclestudy.member.service.output.MemberFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import java.time.Clock; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,7 +54,18 @@ public MemberSaveOutput saveDevice(final MemberSaveInput input) { @Transactional(readOnly = true) public MemberFindOutput findAllMemberDevices(final MemberFindInput input) { final List devices = deviceRepository.findAllByMemberEmail(input.email()); - return MemberFindOutput.of(input.email(), devices); + final LocalTime notificationTime = findNotificationTime(input.email(), devices); + return MemberFindOutput.of(input.email(), notificationTime, devices); + } + + @Nullable + private LocalTime findNotificationTime(final Email email, final List devices) { + if (devices.isEmpty()) { + return memberRepository.findByEmail(email) + .map(Member::getNotificationTime) + .orElse(null); + } + return devices.getFirst().getMember().getNotificationTime(); } @Transactional @@ -76,6 +91,15 @@ public void deleteDevice(final DeviceDeleteInput input) { log.info("[DEVICE_DELETED] 디바이스 삭제 성공: {}", input.targetDeviceIdentifier()); } + @Transactional + public void updateNotificationTime(final MemberNotificationTimeUpdateInput input) { + final Member member = memberRepository.findByIdentifier(input.identifier()) + .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); + member.updateNotificationTime(input.notificationTime()); + log.info("[MEMBER_NOTI_TIME_UPDATED] 멤버 알림 시간 변경: memberId={}, from={}, to={}", + member.getId(), member.getNotificationTime(), input.notificationTime()); + } + private Member saveNewMember(final Email email) { final Optional memberOptional = memberRepository.findByEmail(email); diff --git a/src/main/java/com/recyclestudy/member/service/input/MemberNotificationTimeUpdateInput.java b/src/main/java/com/recyclestudy/member/service/input/MemberNotificationTimeUpdateInput.java new file mode 100644 index 0000000..798e8d8 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/input/MemberNotificationTimeUpdateInput.java @@ -0,0 +1,11 @@ +package com.recyclestudy.member.service.input; + +import com.recyclestudy.member.domain.DeviceIdentifier; +import java.time.LocalTime; + +public record MemberNotificationTimeUpdateInput(DeviceIdentifier identifier, LocalTime notificationTime) { + + public static MemberNotificationTimeUpdateInput of(final DeviceIdentifier identifier, final LocalTime notificationTime) { + return new MemberNotificationTimeUpdateInput(identifier, notificationTime); + } +} diff --git a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java index 9523629..202f1f0 100644 --- a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java +++ b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java @@ -4,15 +4,20 @@ import com.recyclestudy.member.domain.DeviceIdentifier; import com.recyclestudy.member.domain.Email; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; -public record MemberFindOutput(Email email, List elements) { +public record MemberFindOutput(Email email, LocalTime notificationTime, List elements) { - public static MemberFindOutput of(final Email email, final List devices) { + public static MemberFindOutput of( + final Email email, + final LocalTime notificationTime, + final List devices + ) { final List memberFindElements = devices.stream() .map(device -> new MemberFindElement(device.getIdentifier(), device.getCreatedAt())) .toList(); - return new MemberFindOutput(email, memberFindElements); + return new MemberFindOutput(email, notificationTime, memberFindElements); } public record MemberFindElement(DeviceIdentifier identifier, LocalDateTime createdAt) { diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index a499514..c3cbb0d 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -47,7 +47,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final Review savedReview = reviewRepository.save(review); log.info("[REVIEW_SAVED] 복습 주제 저장 성공: reviewId={}", savedReview.getId()); - final List scheduledAts = calculateScheduledAts(input.cycle()); + final List scheduledAts = calculateScheduledAts(input.cycle(), member); final List reviewCycles = scheduledAts.stream() .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt)) @@ -65,16 +65,27 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); } - private List calculateScheduledAts(final CycleSelection cycleSelection) { + private List calculateScheduledAts(final CycleSelection cycleSelection, final Member member) { final CycleSelection resolvedCycle = resolveDefaultCycleIfNull(cycleSelection); final List durations = cycleSelectionResolverRegistry.resolve(resolvedCycle); final LocalDateTime baseTime = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MINUTES); return durations.stream() - .map(baseTime::plus) + .map(duration -> calculateScheduledAt(baseTime, duration, member)) .toList(); } + private LocalDateTime calculateScheduledAt(final LocalDateTime baseTime, final Duration duration, final Member member) { + final LocalDateTime scheduledAt = baseTime.plus(duration); + if (duration.toDays() < 1 || member.getNotificationTime() == null) { + return scheduledAt; + } + final LocalDateTime adjustedTime = scheduledAt.with(member.getNotificationTime()).truncatedTo(ChronoUnit.MINUTES); + log.info("[REVIEW_SCHEDULE_ADJUSTED] 복습 주기 시간 조정: original={}, adjusted={}, memberId={}", + scheduledAt, adjustedTime, member.getId()); + return adjustedTime; + } + @Deprecated // 프론트 마이그레이션 완료 후 제거 예정 private CycleSelection resolveDefaultCycleIfNull(final CycleSelection cycleSelection) { if (cycleSelection != null) { diff --git a/src/main/resources/db/migration/V20260206_1__add_notification_time_to_member.sql b/src/main/resources/db/migration/V20260206_1__add_notification_time_to_member.sql new file mode 100644 index 0000000..f91fa2e --- /dev/null +++ b/src/main/resources/db/migration/V20260206_1__add_notification_time_to_member.sql @@ -0,0 +1 @@ +alter table member add column notification_time time; diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index 561aad3..d38c4c7 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -3,6 +3,7 @@ import com.recyclestudy.email.DeviceAuthEmailSender; import com.recyclestudy.exception.NotFoundException; import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.member.controller.request.MemberNotificationTimeUpdateRequest; import com.recyclestudy.member.controller.request.MemberSaveRequest; import com.recyclestudy.member.domain.ActivationExpiredDateTime; import com.recyclestudy.member.domain.Device; @@ -11,10 +12,12 @@ import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.DeviceRepository; import com.recyclestudy.member.service.MemberService; +import com.recyclestudy.member.service.input.MemberNotificationTimeUpdateInput; import com.recyclestudy.member.service.output.MemberFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import com.recyclestudy.restdocs.APIBaseTest; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; @@ -118,6 +121,7 @@ void findAllMemberDevices() { final MemberFindOutput output = new MemberFindOutput( Email.from(email), + LocalTime.of(9, 0), List.of(device1, device2) ); @@ -139,6 +143,8 @@ void findAllMemberDevices() { ) .responseFields( fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("notificationTime").type(JsonFieldType.STRING) + .description("알림 시간").optional(), fieldWithPath("devices").type(JsonFieldType.ARRAY).description("디바이스 목록"), fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) .description("디바이스 식별자 값"), @@ -422,6 +428,7 @@ void findAllMemberDevices_WithHeader() { final MemberFindOutput output = new MemberFindOutput( Email.from(email), + null, List.of(device1, device2) ); @@ -443,6 +450,8 @@ void findAllMemberDevices_WithHeader() { ) .responseFields( fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("notificationTime").type(JsonFieldType.STRING) + .description("알림 시간").optional(), fieldWithPath("devices").type(JsonFieldType.ARRAY).description("디바이스 목록"), fieldWithPath("devices[].identifier").type(JsonFieldType.STRING) .description("디바이스 식별자 값"), @@ -461,4 +470,38 @@ void findAllMemberDevices_WithHeader() { .statusCode(HttpStatus.OK.value()) .body("devices", hasSize(2)); } + + @Test + @DisplayName("멤버의 알림 시간을 업데이트한다") + void updateNotificationTime() { + // given + final String headerIdentifier = "device-id-1"; + final LocalTime notificationTime = LocalTime.of(9, 0); + final MemberNotificationTimeUpdateRequest request = new MemberNotificationTimeUpdateRequest(notificationTime); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 알림 시간 업데이트") + .description("멤버의 알림 시간을 업데이트한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .requestFields( + fieldWithPath("notificationTime").type(JsonFieldType.STRING).description("알림 시간 (HH:mm:ss)") + ) + )) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("X-Device-Id", headerIdentifier) + .body(request) + .when() + .patch("/api/v1/members/notification-time") + .then() + .statusCode(HttpStatus.OK.value()); + + verify(memberService).updateNotificationTime(any(MemberNotificationTimeUpdateInput.class)); + } } diff --git a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java index 17e74b4..f053903 100644 --- a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java +++ b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java @@ -2,6 +2,7 @@ import com.recyclestudy.exception.BadRequestException; import com.recyclestudy.exception.NotFoundException; +import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.ActivationExpiredDateTime; import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.domain.DeviceIdentifier; @@ -11,17 +12,18 @@ import com.recyclestudy.member.repository.MemberRepository; import com.recyclestudy.member.service.input.DeviceDeleteInput; import com.recyclestudy.member.service.input.MemberFindInput; +import com.recyclestudy.member.service.input.MemberNotificationTimeUpdateInput; import com.recyclestudy.member.service.input.MemberSaveInput; import com.recyclestudy.member.service.output.MemberFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; -import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; @@ -122,7 +125,7 @@ void findAllMemberDevices() { final MemberFindOutput actual = memberService.findAllMemberDevices(input); // then - SoftAssertions.assertSoftly(softAssertions -> { + assertSoftly(softAssertions -> { softAssertions.assertThat(actual.elements()).hasSize(1); softAssertions.assertThat(actual.elements().getFirst().identifier()).isEqualTo(input.deviceIdentifier()); }); @@ -237,4 +240,45 @@ void deleteDevice() { // then verify(deviceRepository).deleteByIdentifier(targetDeviceIdentifier); } + + @Test + @DisplayName("멤버의 알림 시간을 업데이트할 수 있다") + void updateNotificationTime() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final LocalTime notificationTime = LocalTime.of(9, 0); + final MemberNotificationTimeUpdateInput input = new MemberNotificationTimeUpdateInput(identifier, + notificationTime); + final Member member = Member.withoutId(Email.from("test@test.com")); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + + // when + memberService.updateNotificationTime(input); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(member.getNotificationTime()).isNotNull(); + softAssertions.assertThat(member.getNotificationTime()).isEqualTo(notificationTime); + }); + + } + + @Test + @DisplayName("유효하지 않은 디바이스로 알림 시간 업데이트 시 예외를 던진다") + void updateNotificationTime_UnauthorizedDevice() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("invalid-id"); + final LocalTime notificationTime = LocalTime.of(9, 0); + final MemberNotificationTimeUpdateInput input = new MemberNotificationTimeUpdateInput(identifier, + notificationTime); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> memberService.updateNotificationTime(input)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("유효하지 않은 디바이스입니다"); + } } diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index f8c75d8..d1b353f 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -21,6 +21,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.List; @@ -43,7 +44,8 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -class ReviewServiceTest { +class +ReviewServiceTest { @Mock ReviewRepository reviewRepository; @@ -164,4 +166,43 @@ void saveReview_fail_notFoundDevice() { .isInstanceOf(UnauthorizedException.class) .hasMessage("유효하지 않은 디바이스입니다"); } + + @Test + @DisplayName("사용자가 선호 알림 시간을 설정한 경우 1일 이상의 주기는 해당 시간에 맞춰 조정된다") + void saveReview_withPreferredNotificationTime() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final String urlValue = "https://test.com"; + final DefaultCycleSelection cycleSelection = new DefaultCycleSelection("EBBINGHAUS"); + final ReviewSaveInput input = ReviewSaveInput.of(identifier, urlValue, cycleSelection); + + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final LocalTime preferredTime = LocalTime.of(9, 0); + member.updateNotificationTime(preferredTime); + + final Review review = Review.withoutId(member, ReviewURL.from(urlValue)); + + final List durations = List.of(Duration.ofMinutes(10), Duration.ofDays(1)); + + given(memberRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.of(member)); + given(cycleSelectionResolverRegistry.resolve(cycleSelection)).willReturn(durations); + given(reviewRepository.save(any(Review.class))).willReturn(review); + + final ArgumentCaptor> cycleCaptor = ArgumentCaptor.forClass(List.class); + given(reviewCycleRepository.saveAll(cycleCaptor.capture())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + reviewService.saveReview(input); + + // then + final List capturedCycles = cycleCaptor.getValue(); + assertSoftly(softAssertions -> { + softAssertions.assertThat(capturedCycles).hasSize(2); + softAssertions.assertThat(capturedCycles.getFirst().getScheduledAt()) + .isEqualTo(now.plusMinutes(10)); + softAssertions.assertThat(capturedCycles.get(1).getScheduledAt()) + .isEqualTo(now.plusDays(1).with(preferredTime)); + }); + } } From df95bb58b9f35a4100a870664f963fdb096d4dd4 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Fri, 6 Feb 2026 18:14:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?style:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/recyclestudy/member/service/MemberServiceTest.java | 1 - .../com/recyclestudy/review/service/ReviewServiceTest.java | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java index f053903..dc2a194 100644 --- a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java +++ b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java @@ -261,7 +261,6 @@ void updateNotificationTime() { softAssertions.assertThat(member.getNotificationTime()).isNotNull(); softAssertions.assertThat(member.getNotificationTime()).isEqualTo(notificationTime); }); - } @Test diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index d1b353f..814397a 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -44,8 +44,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -class -ReviewServiceTest { +class ReviewServiceTest { @Mock ReviewRepository reviewRepository; From 90b95f24e315b2b6ad66220c6add88ecb87ed7b1 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Fri, 6 Feb 2026 18:14:57 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A9=A4=EB=B2=84=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=8B=9C=EA=B0=84=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=98=20=EC=9D=B4=EC=A0=84=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 시간 업데이트 후 로깅 시 변경 전 시간이 아닌 변경 후 시간이 기록되는 문제 수정 --- .../java/com/recyclestudy/member/service/MemberService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index b0638c1..8aee04f 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -95,9 +95,11 @@ public void deleteDevice(final DeviceDeleteInput input) { public void updateNotificationTime(final MemberNotificationTimeUpdateInput input) { final Member member = memberRepository.findByIdentifier(input.identifier()) .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); + final LocalTime previousNotificationTime = member.getNotificationTime(); + member.updateNotificationTime(input.notificationTime()); log.info("[MEMBER_NOTI_TIME_UPDATED] 멤버 알림 시간 변경: memberId={}, from={}, to={}", - member.getId(), member.getNotificationTime(), input.notificationTime()); + member.getId(), previousNotificationTime, input.notificationTime()); } private Member saveNewMember(final Email email) {