From 95cfc61b8659a5e3c024f5529a119b1e0fbfdd85 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sat, 7 Feb 2026 12:46:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멤버 조회 API에서 알림 시간 조회 기능 분리 --- .../member/controller/MemberController.java | 11 ++ .../response/MemberFindResponse.java | 5 +- .../MemberNotificationTimeFindResponse.java | 10 ++ .../member/service/MemberService.java | 18 ++- .../service/output/MemberFindOutput.java | 6 +- .../MemberNotificationTimeFindOutput.java | 9 ++ .../controller/MemberControllerTest.java | 107 +++++++++++++++++- .../member/service/MemberServiceTest.java | 50 ++++++++ 8 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/recyclestudy/member/controller/response/MemberNotificationTimeFindResponse.java create mode 100644 src/main/java/com/recyclestudy/member/service/output/MemberNotificationTimeFindOutput.java diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index 99e9229..10b81f6 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -5,6 +5,7 @@ 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.MemberNotificationTimeFindResponse; import com.recyclestudy.member.controller.response.MemberSaveResponse; import com.recyclestudy.member.domain.DeviceIdentifier; import com.recyclestudy.member.service.MemberService; @@ -12,6 +13,7 @@ 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.MemberNotificationTimeFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -55,6 +57,15 @@ public ResponseEntity findAllMemberDevices( return ResponseEntity.ok(response); } + @GetMapping("/notification-time") + public ResponseEntity findNotificationTime( + @AuthDevice final DeviceIdentifier identifier + ) { + final MemberNotificationTimeFindOutput output = memberService.findNotificationTime(identifier); + final MemberNotificationTimeFindResponse response = MemberNotificationTimeFindResponse.from(output); + return ResponseEntity.ok(response); + } + @PatchMapping("/notification-time") public ResponseEntity updateNotificationTime( @RequestBody final MemberNotificationTimeUpdateRequest request, 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 219d325..4845dce 100644 --- a/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberFindResponse.java @@ -2,17 +2,16 @@ import com.recyclestudy.member.service.output.MemberFindOutput; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.List; -public record MemberFindResponse(String email, LocalTime notificationTime, List devices) { +public record MemberFindResponse(String email, 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(), output.notificationTime(), memberFindElements); + return new MemberFindResponse(output.email().getValue(), memberFindElements); } private record MemberFindElement(String identifier, LocalDateTime createdAt) { diff --git a/src/main/java/com/recyclestudy/member/controller/response/MemberNotificationTimeFindResponse.java b/src/main/java/com/recyclestudy/member/controller/response/MemberNotificationTimeFindResponse.java new file mode 100644 index 0000000..a5ed117 --- /dev/null +++ b/src/main/java/com/recyclestudy/member/controller/response/MemberNotificationTimeFindResponse.java @@ -0,0 +1,10 @@ +package com.recyclestudy.member.controller.response; + +import com.recyclestudy.member.service.output.MemberNotificationTimeFindOutput; +import java.time.LocalTime; + +public record MemberNotificationTimeFindResponse(LocalTime notificationTime) { + public static MemberNotificationTimeFindResponse from(final MemberNotificationTimeFindOutput output) { + return new MemberNotificationTimeFindResponse(output.notificationTime()); + } +} diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index 8aee04f..a61f2e9 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -15,6 +15,7 @@ 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.MemberNotificationTimeFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import java.time.Clock; import java.time.LocalDateTime; @@ -23,7 +24,6 @@ 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; @@ -54,18 +54,14 @@ public MemberSaveOutput saveDevice(final MemberSaveInput input) { @Transactional(readOnly = true) public MemberFindOutput findAllMemberDevices(final MemberFindInput input) { final List devices = deviceRepository.findAllByMemberEmail(input.email()); - final LocalTime notificationTime = findNotificationTime(input.email(), devices); - return MemberFindOutput.of(input.email(), notificationTime, devices); + return MemberFindOutput.of(input.email(), 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(readOnly = true) + public MemberNotificationTimeFindOutput findNotificationTime(final DeviceIdentifier identifier) { + final Member member = memberRepository.findByIdentifier(identifier) + .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); + return MemberNotificationTimeFindOutput.from(member.getNotificationTime()); } @Transactional 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 202f1f0..391b247 100644 --- a/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java +++ b/src/main/java/com/recyclestudy/member/service/output/MemberFindOutput.java @@ -4,20 +4,18 @@ 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, LocalTime notificationTime, List elements) { +public record MemberFindOutput(Email email, List elements) { 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, notificationTime, memberFindElements); + return new MemberFindOutput(email, memberFindElements); } public record MemberFindElement(DeviceIdentifier identifier, LocalDateTime createdAt) { diff --git a/src/main/java/com/recyclestudy/member/service/output/MemberNotificationTimeFindOutput.java b/src/main/java/com/recyclestudy/member/service/output/MemberNotificationTimeFindOutput.java new file mode 100644 index 0000000..18a228a --- /dev/null +++ b/src/main/java/com/recyclestudy/member/service/output/MemberNotificationTimeFindOutput.java @@ -0,0 +1,9 @@ +package com.recyclestudy.member.service.output; + +import java.time.LocalTime; + +public record MemberNotificationTimeFindOutput(LocalTime notificationTime) { + public static MemberNotificationTimeFindOutput from(final LocalTime notificationTime) { + return new MemberNotificationTimeFindOutput(notificationTime); + } +} diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index d37457e..53c7901 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -14,6 +14,7 @@ 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.MemberNotificationTimeFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import com.recyclestudy.restdocs.APIBaseTest; import java.time.LocalDateTime; @@ -21,6 +22,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -121,7 +123,6 @@ void findAllMemberDevices() { final MemberFindOutput output = new MemberFindOutput( Email.from(email), - LocalTime.of(9, 0), List.of(device1, device2) ); @@ -143,8 +144,6 @@ 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("디바이스 식별자 값"), @@ -428,7 +427,6 @@ void findAllMemberDevices_WithHeader() { final MemberFindOutput output = new MemberFindOutput( Email.from(email), - null, List.of(device1, device2) ); @@ -450,8 +448,6 @@ 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("디바이스 식별자 값"), @@ -471,6 +467,105 @@ void findAllMemberDevices_WithHeader() { .body("devices", hasSize(2)); } + @Test + @DisplayName("멤버의 알림 시간을 조회한다") + void findNotificationTime() { + // given + final String headerIdentifier = "device-id-1"; + final LocalTime notificationTime = LocalTime.of(9, 0); + final MemberNotificationTimeFindOutput output = new MemberNotificationTimeFindOutput(notificationTime); + + given(memberService.findNotificationTime(any(DeviceIdentifier.class))).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 알림 시간 조회") + .description("멤버의 알림 시간을 조회한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("notificationTime").type(JsonFieldType.STRING) + .description("알림 시간 (HH:mm:ss)") + ) + )) + .header("X-Device-Id", headerIdentifier) + .when() + .get("/api/v1/members/notification-time") + .then() + .statusCode(HttpStatus.OK.value()) + .body("notificationTime", equalTo("09:00:00")); + } + + @Test + @DisplayName("알림 시간을 설정하지 않은 멤버 조회 시 null을 반환한다") + void findNotificationTime_NullNotificationTime() { + // given + final String headerIdentifier = "device-id-1"; + final MemberNotificationTimeFindOutput output = new MemberNotificationTimeFindOutput(null); + + given(memberService.findNotificationTime(any(DeviceIdentifier.class))).willReturn(output); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 알림 시간 조회") + .description("알림 시간을 설정하지 않은 멤버 조회 시 null을 반환한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("notificationTime").type(JsonFieldType.STRING) + .description("알림 시간 (미설정 시 null)").optional() + ) + )) + .header("X-Device-Id", headerIdentifier) + .when() + .get("/api/v1/members/notification-time") + .then() + .statusCode(HttpStatus.OK.value()) + .body("notificationTime", Matchers.nullValue()); + } + + @Test + @DisplayName("유효하지 않은 디바이스로 알림 시간 조회 시 401 응답을 반환한다") + void findNotificationTime_UnauthorizedDevice() { + // given + final String headerIdentifier = "invalid-device-id"; + + given(memberService.findNotificationTime(any(DeviceIdentifier.class))) + .willThrow(new UnauthorizedException("유효하지 않은 디바이스입니다")); + + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Member") + .summary("멤버 알림 시간 조회") + .description("유효하지 않은 디바이스로 알림 시간 조회 시 401 응답을 반환한다") + .requestHeaders( + headerWithName("X-Device-Id").description("디바이스 식별자") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) + .header("X-Device-Id", headerIdentifier) + .when() + .get("/api/v1/members/notification-time") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("message", equalTo("유효하지 않은 디바이스입니다")); + } + @Test @DisplayName("멤버의 알림 시간을 업데이트한다") void updateNotificationTime() { diff --git a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java index dc2a194..7e950d0 100644 --- a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java +++ b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java @@ -15,6 +15,7 @@ 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.MemberNotificationTimeFindOutput; import com.recyclestudy.member.service.output.MemberSaveOutput; import java.time.Clock; import java.time.Instant; @@ -241,6 +242,55 @@ void deleteDevice() { verify(deviceRepository).deleteByIdentifier(targetDeviceIdentifier); } + @Test + @DisplayName("디바이스 식별자로 멤버의 알림 시간을 조회한다") + void findNotificationTime() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final Member member = Member.withoutId(Email.from("test@test.com")); + final LocalTime expectedTime = LocalTime.of(9, 0); + member.updateNotificationTime(expectedTime); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + + // when + final MemberNotificationTimeFindOutput actual = memberService.findNotificationTime(identifier); + + // then + assertThat(actual.notificationTime()).isEqualTo(expectedTime); + } + + @Test + @DisplayName("알림 시간을 설정하지 않은 멤버 조회 시 null을 반환한다") + void findNotificationTime_NullNotificationTime() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final Member member = Member.withoutId(Email.from("test@test.com")); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + + // when + final MemberNotificationTimeFindOutput actual = memberService.findNotificationTime(identifier); + + // then + assertThat(actual.notificationTime()).isNull(); + } + + @Test + @DisplayName("유효하지 않은 디바이스로 알림 시간 조회 시 예외를 던진다") + void findNotificationTime_UnauthorizedDevice() { + // given + final DeviceIdentifier identifier = DeviceIdentifier.from("invalid-id"); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> memberService.findNotificationTime(identifier)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("유효하지 않은 디바이스입니다"); + } + @Test @DisplayName("멤버의 알림 시간을 업데이트할 수 있다") void updateNotificationTime() {