From 91d03d03aa5a78519accf8b3629eb0c9154fa20c Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 16:02:54 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Review=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20Member=20=EC=97=B0=EA=B4=80=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recyclestudy/review/domain/Review.java | 17 +++- .../review/service/ReviewService.java | 8 +- .../controller/DeviceControllerTest.java | 45 ++++++---- .../controller/MemberControllerTest.java | 30 ++++--- .../member/domain/DeviceTest.java | 9 +- .../member/service/MemberServiceTest.java | 9 +- .../controller/ReviewControllerTest.java | 82 ++++++++++++++----- .../review/domain/ReviewCycleTest.java | 20 +++-- .../review/domain/ReviewTest.java | 31 +++++-- .../review/domain/ReviewURLTest.java | 3 +- .../review/service/ReviewServiceTest.java | 4 +- 11 files changed, 188 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/recyclestudy/review/domain/Review.java b/src/main/java/com/recyclestudy/review/domain/Review.java index 3b37465..c0488bf 100644 --- a/src/main/java/com/recyclestudy/review/domain/Review.java +++ b/src/main/java/com/recyclestudy/review/domain/Review.java @@ -2,10 +2,14 @@ import com.recyclestudy.common.BaseEntity; import com.recyclestudy.common.NullValidator; +import com.recyclestudy.member.domain.Member; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -21,17 +25,22 @@ @Getter public class Review extends BaseEntity { - public static Review withoutId(final ReviewURL url) { - validateNotNull(url); - return new Review(url); + public static Review withoutId(final Member member, final ReviewURL url) { + validateNotNull(member, url); + return new Review(member, url); } - private static void validateNotNull(final ReviewURL url) { + private static void validateNotNull(final Member member, final ReviewURL url) { NullValidator.builder() + .add(Fields.member, member) .add(Fields.url, url) .validate(); } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + @Embedded @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) private ReviewURL url; diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 954a9da..64c5071 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -34,7 +34,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { .orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다")); checkValidDevice(device); - final Review review = Review.withoutId(input.url()); + final Review review = Review.withoutId(device.getMember(), input.url()); final Review savedReview = reviewRepository.save(review); final LocalDate current = LocalDate.now(clock); @@ -44,9 +44,11 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt, NotificationStatus.PENDING)) .toList(); - final List savedScheduledAts = reviewCycleRepository.saveAll(reviewCycles) - .stream().map(ReviewCycle::getScheduledAt) + final List savedReviewCycles = reviewCycleRepository.saveAll(reviewCycles); + final List savedScheduledAts = savedReviewCycles.stream() + .map(ReviewCycle::getScheduledAt) .toList(); + return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); } diff --git a/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java index e8f962b..b8c1939 100644 --- a/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/DeviceControllerTest.java @@ -45,7 +45,8 @@ void authenticateDevice_Success() { doNothing().when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, resource( @@ -89,7 +90,8 @@ void authenticateDevice_AlreadyAuthenticated() { doThrow(new BadRequestException("이미 인증되었습니다")) .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -120,7 +122,8 @@ void authenticateDevice_InvalidEmailFormat() { final String invalidEmail = "invalid-email"; final String identifier = "device-identifier"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -154,7 +157,8 @@ void authenticateDevice_Expired() { doThrow(new DeviceActivationExpiredException("인증 유효 시간이 만료되었습니다.")) .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -186,7 +190,8 @@ void deleteDevice() { doNothing().when(memberService).deleteDevice(any()); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -217,7 +222,8 @@ void deleteDevice_InvalidIdentifier() { doThrow(new UnauthorizedException("유효하지 않은 디바이스 아이디입니다")) .when(memberService).deleteDevice(any()); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -252,7 +258,8 @@ void deleteDevice_NotOwner() { doThrow(new BadRequestException("디바이스 소유자가 아닙니다.")) .when(memberService).deleteDevice(any()); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -288,7 +295,8 @@ void authenticateDevice_NotFoundMember() { doThrow(new NotFoundException("존재하지 않는 멤버입니다")) .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -322,7 +330,8 @@ void authenticateDevice_NotFoundDevice() { doThrow(new NotFoundException("존재하지 않는 디바이스 아이디입니다")) .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -356,7 +365,8 @@ void authenticateDevice_NotOwner() { doThrow(new BadRequestException("디바이스 소유자가 아닙니다.")) .when(memberService).authenticateDevice(any(Email.class), any(DeviceIdentifier.class)); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -386,7 +396,8 @@ void authenticateDevice_NullEmail() { // given final String identifier = "device-identifier"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -413,7 +424,8 @@ void authenticateDevice_NullIdentifier() { // given final String email = "test@test.com"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -441,7 +453,8 @@ void deleteDevice_NullEmail() { // given final DeviceDeleteRequest request = new DeviceDeleteRequest(null, "device-id", "target-id"); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -473,7 +486,8 @@ void deleteDevice_NullIdentifier() { // given final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", null, "target-id"); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -505,7 +519,8 @@ void deleteDevice_NullTargetIdentifier() { // given final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", "device-id", null); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index e981bd7..9787dda 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -50,7 +50,8 @@ void saveMember() { given(memberService.saveDevice(any())).willReturn(output); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -100,7 +101,8 @@ void findAllMemberDevices() { given(memberService.findAllMemberDevices(any())).willReturn(output); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -143,7 +145,8 @@ void findAllMemberDevices_NotFoundMember() { given(memberService.findAllMemberDevices(any())) .willThrow(new NotFoundException("존재하지 않는 멤버입니다")); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -181,7 +184,8 @@ void findAllMemberDevices_UnauthorizedDevice() { given(memberService.findAllMemberDevices(any())) .willThrow(new UnauthorizedException("인증되지 않은 디바이스입니다")); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -216,7 +220,8 @@ void findAllMemberDevices_InvalidEmailFormat() { final String invalidEmail = "invalid-email"; final String identifier = "device-identifier"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -250,7 +255,8 @@ void saveMember_NullEmail() { // given final MemberSaveRequest request = new MemberSaveRequest(null); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -280,7 +286,8 @@ void saveMember_InvalidEmailFormat() { final String invalidEmail = "invalid-email"; final MemberSaveRequest request = new MemberSaveRequest(invalidEmail); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -313,7 +320,8 @@ void findAllMemberDevices_NotFoundDevice() { given(memberService.findAllMemberDevices(any())) .willThrow(new NotFoundException("존재하지 않는 디바이스 아이디입니다")); - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -347,7 +355,8 @@ void findAllMemberDevices_NullEmail() { // given final String identifier = "device-identifier"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() @@ -377,7 +386,8 @@ void findAllMemberDevices_NullIdentifier() { // given final String email = "test@test.com"; - // when & then + // when + // then given(this.spec) .filter(document(DEFAULT_REST_DOC_PATH, builder() diff --git a/src/test/java/com/recyclestudy/member/domain/DeviceTest.java b/src/test/java/com/recyclestudy/member/domain/DeviceTest.java index c7447a4..c56482c 100644 --- a/src/test/java/com/recyclestudy/member/domain/DeviceTest.java +++ b/src/test/java/com/recyclestudy/member/domain/DeviceTest.java @@ -96,7 +96,8 @@ void activate_fail_expired() { ActivationExpiredDateTime.create(now) ); - // when & then + // when + // then final LocalDateTime expiredTime = now.plusMinutes(6); assertThatThrownBy(() -> device.activate(expiredTime)) .isInstanceOf(RuntimeException.class); @@ -114,7 +115,8 @@ void verifyOwner() { ActivationExpiredDateTime.create(LocalDateTime.now()) ); - // when & then + // when + // then assertThatCode(() -> device.verifyOwner(email)) .doesNotThrowAnyException(); } @@ -131,7 +133,8 @@ void verifyOwner_fail() { ActivationExpiredDateTime.create(LocalDateTime.now()) ); - // when & then + // when + // then final Email otherEmail = Email.from("other@test.com"); assertThatThrownBy(() -> device.verifyOwner(otherEmail)) .isInstanceOf(RuntimeException.class) diff --git a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java index 52a519c..14f0bd1 100644 --- a/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java +++ b/src/test/java/com/recyclestudy/member/service/MemberServiceTest.java @@ -237,7 +237,8 @@ void authenticateDevice_fail_owner() { given(memberRepository.existsByEmail(otherEmail)).willReturn(true); given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); - // when & then + // when + // then assertThatThrownBy(() -> memberService.authenticateDevice(otherEmail, deviceIdentifier)) .isInstanceOf(BadRequestException.class) .hasMessage("디바이스 소유자가 아닙니다."); @@ -275,7 +276,8 @@ void deleteDevice_fail_invalidIdentifier() { given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.empty()); - // when & then + // when + // then assertThatThrownBy(() -> memberService.deleteDevice(input)) .isInstanceOf(UnauthorizedException.class); } @@ -294,7 +296,8 @@ void deleteDevice_fail_owner() { given(deviceRepository.findByIdentifier(deviceIdentifier)).willReturn(Optional.of(device)); - // when & then + // when + // then assertThatThrownBy(() -> memberService.deleteDevice(input)) .isInstanceOf(BadRequestException.class) .hasMessage("디바이스 소유자가 아닙니다."); diff --git a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java index 79c84fc..514a75f 100644 --- a/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/recyclestudy/review/controller/ReviewControllerTest.java @@ -1,41 +1,33 @@ package com.recyclestudy.review.controller; import com.recyclestudy.exception.UnauthorizedException; +import com.recyclestudy.restdocs.APIBaseTest; import com.recyclestudy.review.controller.request.ReviewSaveRequest; import com.recyclestudy.review.domain.ReviewURL; import com.recyclestudy.review.service.ReviewService; import com.recyclestudy.review.service.output.ReviewSaveOutput; -import io.restassured.RestAssured; import java.time.LocalDateTime; import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ReviewControllerTest { - - @LocalServerPort - private int port; +class ReviewControllerTest extends APIBaseTest { @MockitoBean private ReviewService reviewService; - @BeforeEach - void setUp() { - RestAssured.port = port; - } - @Test @DisplayName("리뷰를 저장하면 201 응답을 반환한다") void saveReview() { @@ -47,8 +39,26 @@ void saveReview() { given(reviewService.saveReview(any())).willReturn(output); - // when & then - given() + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("리뷰를 저장하면 201 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"), + fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY) + .description("복습 예정 일시 목록") + ) + )) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when() @@ -67,8 +77,24 @@ void saveReview_Unauthorized() { given(reviewService.saveReview(any())) .willThrow(new UnauthorizedException("유효하지 않은 디바이스입니다")); - // when & then - given() + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("유효하지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when() @@ -87,8 +113,24 @@ void saveReview_InactiveDevice() { given(reviewService.saveReview(any())) .willThrow(new UnauthorizedException("인증되지 않은 디바이스입니다")); - // when & then - given() + // when + // then + given(this.spec) + .filter(document(DEFAULT_REST_DOC_PATH, + builder() + .tag("Review") + .summary("리뷰 저장") + .description("인증되지 않은 디바이스로 리뷰 저장 시 401 응답을 반환한다") + .requestFields( + fieldWithPath("identifier").type(JsonFieldType.STRING) + .description("디바이스 식별자"), + fieldWithPath("url").type(JsonFieldType.STRING) + .description("리뷰할 URL") + ) + .responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("에러 메시지") + ) + )) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when() diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java index 3225928..c168b61 100644 --- a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java +++ b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java @@ -1,5 +1,7 @@ package com.recyclestudy.review.domain; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; import java.time.LocalDateTime; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; @@ -17,7 +19,9 @@ class ReviewCycleTest { @DisplayName("ReviewCycle을 생성할 수 있다") void withoutId() { // given - final Review review = Review.withoutId(ReviewURL.from("https://test.com")); + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); final LocalDateTime scheduledAt = LocalDateTime.now(); final NotificationStatus status = NotificationStatus.PENDING; @@ -33,16 +37,22 @@ void withoutId() { @ParameterizedTest @MethodSource("provideInvalidValue") @DisplayName("null로 생성 시도 시, 예외를 던진다") - void throwExceptionWhenNull(final Review review, final LocalDateTime scheduledAt, - final NotificationStatus status) { + void throwExceptionWhenNull( + final Review review, + final LocalDateTime scheduledAt, + final NotificationStatus status + ) { // given - // when & then + // when + // then assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt, status)) .isInstanceOf(IllegalArgumentException.class); } private static Stream provideInvalidValue() { - final Review review = Review.withoutId(ReviewURL.from("https://test.com")); + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); final LocalDateTime scheduledAt = LocalDateTime.now(); final NotificationStatus status = NotificationStatus.PENDING; diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewTest.java index 16152d6..2007ab6 100644 --- a/src/test/java/com/recyclestudy/review/domain/ReviewTest.java +++ b/src/test/java/com/recyclestudy/review/domain/ReviewTest.java @@ -1,7 +1,13 @@ package com.recyclestudy.review.domain; +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -12,21 +18,36 @@ class ReviewTest { @DisplayName("Review를 생성할 수 있다") void withoutId() { // given + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); final ReviewURL url = ReviewURL.from("https://test.com"); // when - final Review actual = Review.withoutId(url); + final Review actual = Review.withoutId(member, url); // then assertThat(actual.getUrl()).isEqualTo(url); } - @Test + @ParameterizedTest + @MethodSource("provideInvalidValue") @DisplayName("null로 생성 시도 시, 예외를 던진다") - void throwExceptionWhenNull() { + void throwExceptionWhenNull(final Member member, final ReviewURL url) { // given - // when & then - assertThatThrownBy(() -> Review.withoutId(null)) + // when + // then + assertThatThrownBy(() -> Review.withoutId(member, url)) .isInstanceOf(IllegalArgumentException.class); } + + private static Stream provideInvalidValue() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final ReviewURL reviewURL = ReviewURL.from("https://test.com"); + + return Stream.of( + Arguments.of(null, reviewURL), + Arguments.of(member, null) + ); + } } diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java index cf19f5c..b1f3ecb 100644 --- a/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java +++ b/src/test/java/com/recyclestudy/review/domain/ReviewURLTest.java @@ -25,7 +25,8 @@ void from() { @DisplayName("null로 생성 시도 시, 예외를 던진다") void throwExceptionWhenNull() { // given - // when & then + // when + // then assertThatThrownBy(() -> ReviewURL.from(null)) .isInstanceOf(IllegalArgumentException.class); } diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index fe78afd..559db70 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -77,7 +77,9 @@ void saveReview() { ActivationExpiredDateTime.create(now) ); - final Review review = Review.withoutId(ReviewURL.from(urlValue)); + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from(urlValue)); final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1), NotificationStatus.PENDING); given(deviceRepository.findByIdentifier(any())).willReturn(Optional.of(device)); From f472d6f2cea0fac90c78bf102a524647b4ed6f4f Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 16:23:31 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B8=B0=EC=A0=81=20?= =?UTF-8?q?=EB=B3=B5=EC=8A=B5=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 이메일 전송 기능 별도 분리 리팩터링 진행 --- .../recyclestudy/RecyclestudyApplication.java | 2 + .../email/DeviceAuthEmailSender.java | 41 +++++ .../com/recyclestudy/email/EmailSender.java | 37 +++++ .../com/recyclestudy/email/EmailService.java | 64 -------- .../recyclestudy/email/ReviewEmailSender.java | 55 +++++++ .../member/controller/MemberController.java | 6 +- .../repository/ReviewCycleRepository.java | 4 + .../review/service/ReviewCycleService.java | 23 +++ .../review/service/input/ReviewSendInput.java | 12 ++ .../service/output/ReviewSendOutput.java | 32 ++++ .../resources/templates/review_email.html | 17 ++ .../email/DeviceAuthEmailSenderTest.java | 79 +++++++++ .../recyclestudy/email/EmailSenderTest.java | 67 ++++++++ .../email/ReviewEmailSenderTest.java | 150 ++++++++++++++++++ .../controller/MemberControllerTest.java | 6 +- 15 files changed, 525 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java create mode 100644 src/main/java/com/recyclestudy/email/EmailSender.java delete mode 100644 src/main/java/com/recyclestudy/email/EmailService.java create mode 100644 src/main/java/com/recyclestudy/email/ReviewEmailSender.java create mode 100644 src/main/java/com/recyclestudy/review/service/ReviewCycleService.java create mode 100644 src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java create mode 100644 src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java create mode 100644 src/main/resources/templates/review_email.html create mode 100644 src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java create mode 100644 src/test/java/com/recyclestudy/email/EmailSenderTest.java create mode 100644 src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java diff --git a/src/main/java/com/recyclestudy/RecyclestudyApplication.java b/src/main/java/com/recyclestudy/RecyclestudyApplication.java index a95c1cd..89662b6 100644 --- a/src/main/java/com/recyclestudy/RecyclestudyApplication.java +++ b/src/main/java/com/recyclestudy/RecyclestudyApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync +@EnableScheduling @SpringBootApplication public class RecyclestudyApplication { diff --git a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java new file mode 100644 index 0000000..63d8507 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java @@ -0,0 +1,41 @@ +package com.recyclestudy.email; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceAuthEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + + @Value("${auth.base-url}") + private String baseUrl; + + @Async + public void sendDeviceAuthMail(final String email, final String deviceId) { + final String authUrl = createAuthUrl(email, deviceId); + final String message = createMessage(authUrl); + + emailSender.send(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); + + log.info("인증 메일 발송 성공: {}", email); + } + + private String createAuthUrl(final String email, final String deviceId) { + return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", baseUrl, email, deviceId); + } + + private String createMessage(final String authUrl) { + final Context context = new Context(); + context.setVariable("authUrl", authUrl); + return templateEngine.process("auth_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java new file mode 100644 index 0000000..baa04ab --- /dev/null +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -0,0 +1,37 @@ +package com.recyclestudy.email; + +import com.recyclestudy.exception.EmailSendException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailSender { + + private final JavaMailSender javaMailSender; + + public void send(final String to, final String subject, final String content) { + try { + final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + javaMailSender.send(mimeMessage); + + log.info("메일 발송 성공: to={}", to); + + } catch (MessagingException e) { + log.error("메일 전송 실패. to={}", to, e); + throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/recyclestudy/email/EmailService.java b/src/main/java/com/recyclestudy/email/EmailService.java deleted file mode 100644 index 93e5e76..0000000 --- a/src/main/java/com/recyclestudy/email/EmailService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.recyclestudy.email; - -import com.recyclestudy.exception.EmailSendException; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.thymeleaf.TemplateEngine; -import org.thymeleaf.context.Context; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailService { - - private final JavaMailSender javaMailSender; - private final TemplateEngine templateEngine; - - @Value("${auth.base-url}") - private String baseUrl; - - @Async - public void sendDeviceAuthMail(String email, String deviceId) { - try { - final String authUrl = createAuthUrl(email, deviceId); - final String message = createMessage(authUrl); - - sendMail(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); - - log.info("인증 메일 발송 성공: {}", email); - - } catch (Exception e) { - log.error("메일 전송 실패. email={}, deviceId={}", email, deviceId, e); - - throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); - } - } - - private String createAuthUrl(String email, String deviceId) { - return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", baseUrl, email, deviceId); - } - - private String createMessage(String authUrl) { - final Context context = new Context(); - context.setVariable("authUrl", authUrl); - return templateEngine.process("auth_email", context); - } - - private void sendMail(String to, String subject, String content) throws MessagingException { - final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); - final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); - - helper.setTo(to); - helper.setSubject(subject); - helper.setText(content, true); - - javaMailSender.send(mimeMessage); - } -} diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java new file mode 100644 index 0000000..4009411 --- /dev/null +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -0,0 +1,55 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.ReviewCycleService; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewEmailSender { + + private final EmailSender emailSender; + private final TemplateEngine templateEngine; + private final ReviewCycleService reviewCycleService; + private final Clock clock; + + @Async + @Scheduled(cron = "0 0 8 * * *") + public void sendReviewMail() { + + final LocalDate targetDate = LocalDate.now(clock); + final LocalTime targetTime = LocalTime.of(8, 0); + + final ReviewSendOutput targetReviewCycle = reviewCycleService.findTargetReviewCycle( + ReviewSendInput.from(targetDate, targetTime)); + + for (final ReviewSendElement element : targetReviewCycle.elements()) { + final String message = createMessage(element.targetUrls()); + final Email targetEmail = element.email(); + + emailSender.send(targetEmail.getValue(), "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); + log.info("복습 메일 발송 성공: email={}", targetEmail); + } + } + + private String createMessage(final List targetUrls) { + final Context context = new Context(); + context.setVariable("targetUrls", targetUrls); + return templateEngine.process("review_email", context); + } +} diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index fa195d6..0ab392c 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -1,6 +1,6 @@ package com.recyclestudy.member.controller; -import com.recyclestudy.email.EmailService; +import com.recyclestudy.email.DeviceAuthEmailSender; import com.recyclestudy.member.controller.request.MemberSaveRequest; import com.recyclestudy.member.controller.response.MemberFindResponse; import com.recyclestudy.member.controller.response.MemberSaveResponse; @@ -25,14 +25,14 @@ public class MemberController { private final MemberService memberService; - private final EmailService emailService; + private final DeviceAuthEmailSender deviceAuthEmailSender; @PostMapping public ResponseEntity saveMember(@RequestBody final MemberSaveRequest request) { final MemberSaveInput input = request.toInput(); final MemberSaveOutput output = memberService.saveDevice(input); - emailService.sendDeviceAuthMail(output.email().getValue(), output.identifier().getValue()); + deviceAuthEmailSender.sendDeviceAuthMail(output.email().getValue(), output.identifier().getValue()); final MemberSaveResponse response = MemberSaveResponse.from(output); return ResponseEntity.status(HttpStatus.CREATED).body(response); diff --git a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java index 8630fac..3060ad0 100644 --- a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java +++ b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java @@ -1,7 +1,11 @@ package com.recyclestudy.review.repository; import com.recyclestudy.review.domain.ReviewCycle; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewCycleRepository extends JpaRepository { + + List findAllByScheduledAt(LocalDateTime scheduledAt); } diff --git a/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java new file mode 100644 index 0000000..9dc708d --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java @@ -0,0 +1,23 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewCycleService { + + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional(readOnly = true) + public ReviewSendOutput findTargetReviewCycle(final ReviewSendInput input) { + final List targetCycle = reviewCycleRepository.findAllByScheduledAt(input.scheduledAt()); + return ReviewSendOutput.from(targetCycle); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java new file mode 100644 index 0000000..cee411c --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/input/ReviewSendInput.java @@ -0,0 +1,12 @@ +package com.recyclestudy.review.service.input; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public record ReviewSendInput(LocalDateTime scheduledAt) { + + public static ReviewSendInput from(final LocalDate targetDate, final LocalTime targetTime) { + return new ReviewSendInput(LocalDateTime.of(targetDate, targetTime)); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java new file mode 100644 index 0000000..19f7fae --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java @@ -0,0 +1,32 @@ +package com.recyclestudy.review.service.output; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import java.util.List; +import java.util.stream.Collectors; + +public record ReviewSendOutput(List elements) { + + public static ReviewSendOutput from(final List reviewCycles) { + final List elements = reviewCycles.stream() + .collect(Collectors.groupingBy( + cycle -> cycle.getReview().getMember().getEmail(), + Collectors.mapping( + cycle -> cycle.getReview().getUrl(), + Collectors.toList() + ) + )) + .entrySet().stream() + .map(entry -> ReviewSendElement.of(entry.getKey(), entry.getValue())) + .toList(); + + return new ReviewSendOutput(elements); + } + + public record ReviewSendElement(Email email, List targetUrls) { + public static ReviewSendElement of(final Email email, final List targetUrl) { + return new ReviewSendElement(email, targetUrl); + } + } +} diff --git a/src/main/resources/templates/review_email.html b/src/main/resources/templates/review_email.html new file mode 100644 index 0000000..d989b8b --- /dev/null +++ b/src/main/resources/templates/review_email.html @@ -0,0 +1,17 @@ + + + + 오늘의 복습 목록 + + +

오늘 복습할 목록입니다.

+

아래 링크들을 클릭하여 복습을 진행하세요.

+ + + diff --git a/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java new file mode 100644 index 0000000..254d909 --- /dev/null +++ b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java @@ -0,0 +1,79 @@ +package com.recyclestudy.email; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class DeviceAuthEmailSenderTest { + + @Mock + private EmailSender emailSender; + + @Mock + private TemplateEngine templateEngine; + + private DeviceAuthEmailSender deviceAuthEmailSender; + + @BeforeEach + void setUp() { + deviceAuthEmailSender = new DeviceAuthEmailSender(emailSender, templateEngine); + ReflectionTestUtils.setField(deviceAuthEmailSender, "baseUrl", "https://example.com"); + } + + @Test + @DisplayName("디바이스 인증 메일을 발송한다") + void sendDeviceAuthMail_success() { + // given + final String email = "test@test.com"; + final String deviceId = "device-123"; + final String expectedHtml = "인증 링크"; + + given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(expectedHtml); + + // when + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceId); + + // then + verify(emailSender).send( + eq(email), + eq("[Recycle Study] 디바이스 인증을 완료해주세요."), + eq(expectedHtml) + ); + } + + @Test + @DisplayName("올바른 인증 URL이 템플릿에 전달된다") + void sendDeviceAuthMail_correctAuthUrl() { + // given + final String email = "test@test.com"; + final String deviceId = "device-123"; + final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(""); + + // when + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceId); + + // then + verify(templateEngine).process(eq("auth_email"), contextCaptor.capture()); + + final Context capturedContext = contextCaptor.getValue(); + final String authUrl = (String) capturedContext.getVariable("authUrl"); + + assertThat(authUrl).isEqualTo("https://example.com/api/v1/device/auth?email=test@test.com&identifier=device-123"); + } +} diff --git a/src/test/java/com/recyclestudy/email/EmailSenderTest.java b/src/test/java/com/recyclestudy/email/EmailSenderTest.java new file mode 100644 index 0000000..b78599a --- /dev/null +++ b/src/test/java/com/recyclestudy/email/EmailSenderTest.java @@ -0,0 +1,67 @@ +package com.recyclestudy.email; + +import com.recyclestudy.exception.EmailSendException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class EmailSenderTest { + + @Mock + private JavaMailSender javaMailSender; + + @InjectMocks + private EmailSender emailSender; + + @Test + @DisplayName("메일을 성공적으로 발송한다") + void send_success() { + // given + final String to = "test@test.com"; + final String subject = "테스트 제목"; + final String content = "테스트 내용"; + final MimeMessage mimeMessage = mock(MimeMessage.class); + + given(javaMailSender.createMimeMessage()).willReturn(mimeMessage); + + // when + emailSender.send(to, subject, content); + + // then + verify(javaMailSender).createMimeMessage(); + verify(javaMailSender).send(mimeMessage); + } + + @Test + @DisplayName("메일 발송 실패 시 EmailSendException을 던진다") + void send_fail_throwsException() throws MessagingException { + // given + final String to = "test@test.com"; + final String subject = "테스트 제목"; + final String content = "테스트 내용"; + final MimeMessage mimeMessage = mock(MimeMessage.class); + + given(javaMailSender.createMimeMessage()).willReturn(mimeMessage); + willThrow(new MessagingException("메일 서버 오류")) + .given(mimeMessage).setRecipient(any(), any()); + + // when & then + assertThatThrownBy(() -> emailSender.send(to, subject, content)) + .isInstanceOf(EmailSendException.class) + .hasMessage("메일 전송 중 오류가 발생했습니다."); + } +} diff --git a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java new file mode 100644 index 0000000..5e98655 --- /dev/null +++ b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java @@ -0,0 +1,150 @@ +package com.recyclestudy.email; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.ReviewCycleService; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewEmailSenderTest { + + @Mock + private EmailSender emailSender; + + @Mock + private TemplateEngine templateEngine; + + @Mock + private ReviewCycleService reviewCycleService; + + private Clock fixedClock; + + private ReviewEmailSender reviewEmailSender; + + @BeforeEach + void setUp() { + fixedClock = Clock.fixed( + Instant.parse("2025-01-01T08:00:00Z"), + ZoneId.of("UTC") + ); + reviewEmailSender = new ReviewEmailSender(emailSender, templateEngine, reviewCycleService, fixedClock); + } + + @Test + @DisplayName("복습 대상자에게 메일을 발송한다") + void sendReviewMail_success() { + // given + final Email targetEmail = Email.from("user@test.com"); + final List targetUrls = List.of( + ReviewURL.from("https://example.com/article1"), + ReviewURL.from("https://example.com/article2") + ); + final ReviewSendElement element = ReviewSendElement.of(targetEmail, targetUrls); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn("복습 목록"); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender).send( + eq("user@test.com"), + eq("[Recycle Study] 오늘의 복습 목록이 도착했습니다"), + eq("복습 목록") + ); + } + + @Test + @DisplayName("여러 대상자에게 각각 메일을 발송한다") + void sendReviewMail_multipleRecipients() { + // given + final ReviewSendElement element1 = ReviewSendElement.of( + Email.from("user1@test.com"), + List.of(ReviewURL.from("https://example.com/1")) + ); + final ReviewSendElement element2 = ReviewSendElement.of( + Email.from("user2@test.com"), + List.of(ReviewURL.from("https://example.com/2")) + ); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element1, element2)); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender, times(2)).send(any(), any(), any()); + verify(emailSender).send(eq("user1@test.com"), any(), any()); + verify(emailSender).send(eq("user2@test.com"), any(), any()); + } + + @Test + @DisplayName("복습 대상이 없으면 메일을 발송하지 않는다") + void sendReviewMail_noRecipients() { + // given + final ReviewSendOutput output = new ReviewSendOutput(List.of()); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(emailSender, never()).send(any(), any(), any()); + } + + @Test + @DisplayName("템플릿에 복습 URL 목록이 전달된다") + void sendReviewMail_templateReceivesUrls() { + // given + final List targetUrls = List.of( + ReviewURL.from("https://example.com/article1"), + ReviewURL.from("https://example.com/article2") + ); + final ReviewSendElement element = ReviewSendElement.of(Email.from("user@test.com"), targetUrls); + final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); + final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); + given(templateEngine.process(eq("review_email"), any(Context.class))).willReturn(""); + + // when + reviewEmailSender.sendReviewMail(); + + // then + verify(templateEngine).process(eq("review_email"), contextCaptor.capture()); + + final Context capturedContext = contextCaptor.getValue(); + final List capturedUrls = (List) capturedContext.getVariable("targetUrls"); + + assertThat(capturedUrls).hasSize(2); + assertThat(capturedUrls.getFirst().getValue()).isEqualTo("https://example.com/article1"); + assertThat(capturedUrls.get(1).getValue()).isEqualTo("https://example.com/article2"); + } +} diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index 9787dda..f636e76 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -1,6 +1,6 @@ package com.recyclestudy.member.controller; -import com.recyclestudy.email.EmailService; +import com.recyclestudy.email.DeviceAuthEmailSender; import com.recyclestudy.exception.NotFoundException; import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.controller.request.MemberSaveRequest; @@ -37,7 +37,7 @@ class MemberControllerTest extends APIBaseTest { private MemberService memberService; @MockitoBean - private EmailService emailService; + private DeviceAuthEmailSender deviceAuthEmailSender; @Test @DisplayName("이메일을 통해 새로운 디바이스를 저장한다") @@ -75,7 +75,7 @@ void saveMember() { .body("email", equalTo(email)) .body("identifier", equalTo(identifier)); - verify(emailService).sendDeviceAuthMail(email, identifier); + verify(deviceAuthEmailSender).sendDeviceAuthMail(email, identifier); } @Test From a8ebc187f118d5473cbe10c4c53e351b6304d6b0 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 17:07:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test:=20ReviewCycleServiceTest=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 --- .../service/ReviewCycleServiceTest.java | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java diff --git a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java new file mode 100644 index 0000000..bb00414 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java @@ -0,0 +1,151 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import com.recyclestudy.review.service.input.ReviewSendInput; +import com.recyclestudy.review.service.output.ReviewSendOutput; +import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.assertj.core.api.SoftAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewCycleServiceTest { + + @Mock + private ReviewCycleRepository reviewCycleRepository; + + @InjectMocks + private ReviewCycleService reviewCycleService; + + @Test + @DisplayName("스케줄된 시간에 해당하는 복습 사이클을 조회한다") + void findTargetReviewCycle_success() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member = Member.withoutId(Email.from("user@test.com")); + final Review review = Review.withoutId(member, ReviewURL.from("https://example.com/article")); + final ReviewCycle reviewCycle = ReviewCycle.withoutId(review, scheduledAt, NotificationStatus.PENDING); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(reviewCycle)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(1); + softAssertions.assertThat(result.elements().getFirst().email()).isEqualTo(Email.from("user@test.com")); + }); + verify(reviewCycleRepository).findAllByScheduledAt(scheduledAt); + } + + @Test + @DisplayName("복습 대상이 없으면 빈 결과를 반환한다") + void findTargetReviewCycle_empty() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of()); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + assertThat(result.elements()).isEmpty(); + } + + @Test + @DisplayName("동일 사용자의 여러 복습 URL을 하나의 요소로 그룹화한다") + void findTargetReviewCycle_groupByEmail() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member = Member.withoutId(Email.from("user@test.com")); + final Review review1 = Review.withoutId(member, ReviewURL.from("https://example.com/article1")); + final Review review2 = Review.withoutId(member, ReviewURL.from("https://example.com/article2")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(1); + + final ReviewSendElement element = result.elements().getFirst(); + softAssertions.assertThat(element.email()).isEqualTo(Email.from("user@test.com")); + softAssertions.assertThat(element.targetUrls()).hasSize(2); + }); + } + + @Test + @DisplayName("여러 사용자의 복습 사이클을 각각 그룹화하여 반환한다") + void findTargetReviewCycle_multipleUsers() { + // given + final LocalDateTime scheduledAt = LocalDateTime.of(2025, 1, 1, 8, 0); + final ReviewSendInput input = ReviewSendInput.from( + LocalDate.of(2025, 1, 1), + LocalTime.of(8, 0) + ); + + final Member member1 = Member.withoutId(Email.from("user1@test.com")); + final Member member2 = Member.withoutId(Email.from("user2@test.com")); + final Review review1 = Review.withoutId(member1, ReviewURL.from("https://example.com/article1")); + final Review review2 = Review.withoutId(member2, ReviewURL.from("https://example.com/article2")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING); + + given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); + + // when + final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(result.elements()).hasSize(2); + + final List emails = result.elements().stream() + .map(ReviewSendElement::email) + .toList(); + softAssertions.assertThat(emails).containsExactlyInAnyOrder( + Email.from("user1@test.com"), + Email.from("user2@test.com") + ); + }); + } +} From 21383cc950c8918f3b5a72e63dd80e77e73f76e4 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 17:14:19 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20ReviewSendOutput=20collect=20?= =?UTF-8?q?=EB=82=B4=20=EB=B6=88=EB=B3=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recyclestudy/review/service/output/ReviewSendOutput.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java index 19f7fae..0c1a25f 100644 --- a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java @@ -14,7 +14,7 @@ public static ReviewSendOutput from(final List reviewCycles) { cycle -> cycle.getReview().getMember().getEmail(), Collectors.mapping( cycle -> cycle.getReview().getUrl(), - Collectors.toList() + Collectors.toUnmodifiableList() ) )) .entrySet().stream() From e3b837cbfe587822c9e68ec010574c8b7c4bd3f8 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 17:18:38 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20html=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EC=97=90=20lang=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/auth_email.html | 2 +- src/main/resources/templates/review_email.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/templates/auth_email.html b/src/main/resources/templates/auth_email.html index fe01306..c7648c8 100644 --- a/src/main/resources/templates/auth_email.html +++ b/src/main/resources/templates/auth_email.html @@ -1,5 +1,5 @@ - + 이메일 인증 diff --git a/src/main/resources/templates/review_email.html b/src/main/resources/templates/review_email.html index d989b8b..f24e9c5 100644 --- a/src/main/resources/templates/review_email.html +++ b/src/main/resources/templates/review_email.html @@ -1,5 +1,5 @@ - + 오늘의 복습 목록 From 2e3bb3e19f9c7497f10ec1c6c6aa2d025cdec76b Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 18:17:38 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=9D=B4=EB=A0=A5=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/recyclestudy/email/EmailSender.java | 8 +- .../recyclestudy/email/ReviewEmailSender.java | 33 ++++++- .../review/domain/NotificationHistory.java | 52 ++++++++++++ .../review/domain/ReviewCycle.java | 26 ++---- .../NotificationHistoryRepository.java | 7 ++ .../service/NotificationHistoryService.java | 30 +++++++ .../review/service/ReviewService.java | 16 +++- .../service/output/ReviewSendOutput.java | 30 +++++-- .../email/ReviewEmailSenderTest.java | 44 +++++----- .../domain/NotificationHistoryTest.java | 66 ++++++++++++++ .../review/domain/ReviewCycleTest.java | 17 ++-- .../NotificationHistoryServiceTest.java | 85 +++++++++++++++++++ .../service/ReviewCycleServiceTest.java | 20 ++--- .../review/service/ReviewServiceTest.java | 27 ++++-- 14 files changed, 374 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/recyclestudy/review/domain/NotificationHistory.java create mode 100644 src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java create mode 100644 src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java create mode 100644 src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java create mode 100644 src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java index baa04ab..c6e500a 100644 --- a/src/main/java/com/recyclestudy/email/EmailSender.java +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -16,21 +16,21 @@ public class EmailSender { private final JavaMailSender javaMailSender; - public void send(final String to, final String subject, final String content) { + public void send(final String targetEmail, final String subject, final String content) { try { final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); - helper.setTo(to); + helper.setTo(targetEmail); helper.setSubject(subject); helper.setText(content, true); javaMailSender.send(mimeMessage); - log.info("메일 발송 성공: to={}", to); + log.info("메일 발송 성공: email={}", targetEmail); } catch (MessagingException e) { - log.error("메일 전송 실패. to={}", to, e); + log.error("메일 발송 실패: email={}", targetEmail, e); throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); } } diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java index 4009411..feeefd2 100644 --- a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -1,7 +1,9 @@ package com.recyclestudy.email; import com.recyclestudy.member.domain.Email; +import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.NotificationHistoryService; import com.recyclestudy.review.service.ReviewCycleService; import com.recyclestudy.review.service.input.ReviewSendInput; import com.recyclestudy.review.service.output.ReviewSendOutput; @@ -12,7 +14,6 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.thymeleaf.TemplateEngine; @@ -26,9 +27,9 @@ public class ReviewEmailSender { private final EmailSender emailSender; private final TemplateEngine templateEngine; private final ReviewCycleService reviewCycleService; + private final NotificationHistoryService notificationHistoryService; private final Clock clock; - @Async @Scheduled(cron = "0 0 8 * * *") public void sendReviewMail() { @@ -38,12 +39,36 @@ public void sendReviewMail() { final ReviewSendOutput targetReviewCycle = reviewCycleService.findTargetReviewCycle( ReviewSendInput.from(targetDate, targetTime)); - for (final ReviewSendElement element : targetReviewCycle.elements()) { + final List elements = targetReviewCycle.elements(); + log.info("복습 메일 발송 시작: 대상 {}명", elements.size()); + + int successCount = 0; + int failCount = 0; + + for (final ReviewSendElement element : elements) { final String message = createMessage(element.targetUrls()); final Email targetEmail = element.email(); + final boolean success = sendToTargetEmail(targetEmail, message); + + if (success) { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.SENT); + successCount++; + } else { + notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.FAILED); + failCount++; + } + } + + log.info("복습 메일 발송 처리 완료: 성공 {}명, 실패 {}명", successCount, failCount); + } + + private boolean sendToTargetEmail(final Email targetEmail, final String message) { + try { emailSender.send(targetEmail.getValue(), "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); - log.info("복습 메일 발송 성공: email={}", targetEmail); + return true; + } catch (final Exception e) { + return false; } } diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java new file mode 100644 index 0000000..350a83f --- /dev/null +++ b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java @@ -0,0 +1,52 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.common.NullValidator; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "notification_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@FieldNameConstants(level = AccessLevel.PRIVATE) +@Getter +public class NotificationHistory extends BaseEntity { + + public static NotificationHistory withoutId( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + validateNotNull(reviewCycle, status); + return new NotificationHistory(reviewCycle, status); + } + + private static void validateNotNull( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + NullValidator.builder() + .add(Fields.reviewCycle, reviewCycle) + .add(Fields.status, status) + .validate(); + } + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_cycle_id", nullable = false) + private ReviewCycle reviewCycle; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private NotificationStatus status; +} diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java index a56df10..1f0bd95 100644 --- a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java @@ -2,10 +2,7 @@ import com.recyclestudy.common.BaseEntity; import com.recyclestudy.common.NullValidator; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -18,31 +15,22 @@ import lombok.experimental.FieldNameConstants; @Entity -@Table(name = "cycle") +@Table(name = "review_cycle") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @FieldNameConstants(level = AccessLevel.PRIVATE) @Getter public class ReviewCycle extends BaseEntity { - public static ReviewCycle withoutId( - final Review review, - final LocalDateTime scheduledAt, - final NotificationStatus status - ) { - validateNotNull(review, scheduledAt, status); - return new ReviewCycle(review, scheduledAt, status); + public static ReviewCycle withoutId(final Review review, final LocalDateTime scheduledAt) { + validateNotNull(review, scheduledAt); + return new ReviewCycle(review, scheduledAt); } - private static void validateNotNull( - final Review review, - final LocalDateTime scheduledAt, - final NotificationStatus status - ) { + private static void validateNotNull(final Review review, final LocalDateTime scheduledAt) { NullValidator.builder() .add(Fields.review, review) .add(Fields.scheduledAt, scheduledAt) - .add(Fields.status, status) .validate(); } @@ -51,8 +39,4 @@ private static void validateNotNull( private Review review; private LocalDateTime scheduledAt; - - @Enumerated(value = EnumType.STRING) - @Column(name = "status", nullable = false) - private NotificationStatus status; } diff --git a/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java new file mode 100644 index 0000000..fa496fd --- /dev/null +++ b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java @@ -0,0 +1,7 @@ +package com.recyclestudy.review.repository; + +import com.recyclestudy.review.domain.NotificationHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java new file mode 100644 index 0000000..1e9e61a --- /dev/null +++ b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java @@ -0,0 +1,30 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationHistoryService { + + private final NotificationHistoryRepository notificationHistoryRepository; + private final ReviewCycleRepository reviewCycleRepository; + + @Transactional + public void saveAll(final List reviewCycleIds, final NotificationStatus status) { + final List reviewCycles = reviewCycleRepository.findAllById(reviewCycleIds); + + final List histories = reviewCycles.stream() + .map(cycle -> NotificationHistory.withoutId(cycle, status)) + .toList(); + + notificationHistoryRepository.saveAll(histories); + } +} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 64c5071..19b174c 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -3,10 +3,12 @@ import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.review.domain.NotificationHistory; import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; import com.recyclestudy.review.domain.ReviewCycleDuration; +import com.recyclestudy.review.repository.NotificationHistoryRepository; import com.recyclestudy.review.repository.ReviewCycleRepository; import com.recyclestudy.review.repository.ReviewRepository; import com.recyclestudy.review.service.input.ReviewSaveInput; @@ -26,6 +28,7 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final ReviewCycleRepository reviewCycleRepository; private final DeviceRepository deviceRepository; + private final NotificationHistoryRepository notificationHistoryRepository; private final Clock clock; @Transactional @@ -41,7 +44,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final List scheduledAts = ReviewCycleDuration.calculate(current); final List reviewCycles = scheduledAts.stream() - .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt, NotificationStatus.PENDING)) + .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt)) .toList(); final List savedReviewCycles = reviewCycleRepository.saveAll(reviewCycles); @@ -49,12 +52,21 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { .map(ReviewCycle::getScheduledAt) .toList(); + savePendingNotificationHistory(savedReviewCycles); + return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); } - private static void checkValidDevice(final Device device) { + private void checkValidDevice(final Device device) { if (!device.isActive()) { throw new UnauthorizedException("인증되지 않은 디바이스입니다"); } } + + private void savePendingNotificationHistory(final List savedReviewCycles) { + final List notificationHistories = savedReviewCycles.stream() + .map(reviewCycle -> NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING)) + .toList(); + notificationHistoryRepository.saveAll(notificationHistories); + } } diff --git a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java index 0c1a25f..df9b0c1 100644 --- a/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java +++ b/src/main/java/com/recyclestudy/review/service/output/ReviewSendOutput.java @@ -12,21 +12,35 @@ public static ReviewSendOutput from(final List reviewCycles) { final List elements = reviewCycles.stream() .collect(Collectors.groupingBy( cycle -> cycle.getReview().getMember().getEmail(), - Collectors.mapping( - cycle -> cycle.getReview().getUrl(), - Collectors.toUnmodifiableList() - ) + Collectors.toUnmodifiableList() )) .entrySet().stream() - .map(entry -> ReviewSendElement.of(entry.getKey(), entry.getValue())) + .map(entry -> { + final List cycles = entry.getValue(); + final List cycleIds = cycles.stream() + .map(ReviewCycle::getId) + .toList(); + final List urls = cycles.stream() + .map(cycle -> cycle.getReview().getUrl()) + .toList(); + return ReviewSendElement.of(entry.getKey(), cycleIds, urls); + }) .toList(); return new ReviewSendOutput(elements); } - public record ReviewSendElement(Email email, List targetUrls) { - public static ReviewSendElement of(final Email email, final List targetUrl) { - return new ReviewSendElement(email, targetUrl); + public record ReviewSendElement( + Email email, + List reviewCycleIds, + List targetUrls + ) { + public static ReviewSendElement of( + final Email email, + final List reviewCycleIds, + final List targetUrls + ) { + return new ReviewSendElement(email, reviewCycleIds, targetUrls); } } } diff --git a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java index 5e98655..714eb89 100644 --- a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java +++ b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java @@ -2,6 +2,7 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.service.NotificationHistoryService; import com.recyclestudy.review.service.ReviewCycleService; import com.recyclestudy.review.service.output.ReviewSendOutput; import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement; @@ -9,17 +10,18 @@ import java.time.Instant; import java.time.ZoneId; import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -31,37 +33,34 @@ class ReviewEmailSenderTest { @Mock - private EmailSender emailSender; + EmailSender emailSender; @Mock - private TemplateEngine templateEngine; + TemplateEngine templateEngine; @Mock - private ReviewCycleService reviewCycleService; + ReviewCycleService reviewCycleService; - private Clock fixedClock; + @Mock + NotificationHistoryService notificationHistoryService; - private ReviewEmailSender reviewEmailSender; + @Spy + Clock clock = Clock.fixed(Instant.parse("2025-01-01T08:00:00Z"), ZoneId.of("UTC")); - @BeforeEach - void setUp() { - fixedClock = Clock.fixed( - Instant.parse("2025-01-01T08:00:00Z"), - ZoneId.of("UTC") - ); - reviewEmailSender = new ReviewEmailSender(emailSender, templateEngine, reviewCycleService, fixedClock); - } + @InjectMocks + ReviewEmailSender reviewEmailSender; @Test @DisplayName("복습 대상자에게 메일을 발송한다") void sendReviewMail_success() { // given final Email targetEmail = Email.from("user@test.com"); + final List reviewCycleIds = List.of(1L, 2L); final List targetUrls = List.of( ReviewURL.from("https://example.com/article1"), ReviewURL.from("https://example.com/article2") ); - final ReviewSendElement element = ReviewSendElement.of(targetEmail, targetUrls); + final ReviewSendElement element = ReviewSendElement.of(targetEmail, reviewCycleIds, targetUrls); final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); given(reviewCycleService.findTargetReviewCycle(any())).willReturn(output); @@ -84,10 +83,12 @@ void sendReviewMail_multipleRecipients() { // given final ReviewSendElement element1 = ReviewSendElement.of( Email.from("user1@test.com"), + List.of(1L), List.of(ReviewURL.from("https://example.com/1")) ); final ReviewSendElement element2 = ReviewSendElement.of( Email.from("user2@test.com"), + List.of(2L), List.of(ReviewURL.from("https://example.com/2")) ); final ReviewSendOutput output = new ReviewSendOutput(List.of(element1, element2)); @@ -123,11 +124,12 @@ void sendReviewMail_noRecipients() { @DisplayName("템플릿에 복습 URL 목록이 전달된다") void sendReviewMail_templateReceivesUrls() { // given + final List reviewCycleIds = List.of(1L, 2L); final List targetUrls = List.of( ReviewURL.from("https://example.com/article1"), ReviewURL.from("https://example.com/article2") ); - final ReviewSendElement element = ReviewSendElement.of(Email.from("user@test.com"), targetUrls); + final ReviewSendElement element = ReviewSendElement.of(Email.from("user@test.com"), reviewCycleIds, targetUrls); final ReviewSendOutput output = new ReviewSendOutput(List.of(element)); final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); @@ -143,8 +145,10 @@ void sendReviewMail_templateReceivesUrls() { final Context capturedContext = contextCaptor.getValue(); final List capturedUrls = (List) capturedContext.getVariable("targetUrls"); - assertThat(capturedUrls).hasSize(2); - assertThat(capturedUrls.getFirst().getValue()).isEqualTo("https://example.com/article1"); - assertThat(capturedUrls.get(1).getValue()).isEqualTo("https://example.com/article2"); + assertSoftly(softAssertions -> { + softAssertions.assertThat(capturedUrls).hasSize(2); + softAssertions.assertThat(capturedUrls.getFirst().getValue()).isEqualTo("https://example.com/article1"); + softAssertions.assertThat(capturedUrls.get(1).getValue()).isEqualTo("https://example.com/article2"); + }); } } diff --git a/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java b/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java new file mode 100644 index 0000000..1bb583e --- /dev/null +++ b/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java @@ -0,0 +1,66 @@ +package com.recyclestudy.review.domain; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import java.time.LocalDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +class NotificationHistoryTest { + + @Test + @DisplayName("NotificationHistory를 생성할 수 있다") + void withoutId() { + // given + final ReviewCycle reviewCycle = createReviewCycle(); + final NotificationStatus status = NotificationStatus.PENDING; + + // when + final NotificationHistory actual = NotificationHistory.withoutId(reviewCycle, status); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.getReviewCycle()).isEqualTo(reviewCycle); + softAssertions.assertThat(actual.getStatus()).isEqualTo(status); + }); + } + + @ParameterizedTest + @MethodSource("provideInvalidValue") + @DisplayName("null로 생성 시도 시, 예외를 던진다") + void throwExceptionWhenNull( + final ReviewCycle reviewCycle, + final NotificationStatus status + ) { + // given + // when + // then + assertThatThrownBy(() -> NotificationHistory.withoutId(reviewCycle, status)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static Stream provideInvalidValue() { + final ReviewCycle reviewCycle = createReviewCycle(); + final NotificationStatus status = NotificationStatus.PENDING; + + return Stream.of( + Arguments.of(null, status), + Arguments.of(reviewCycle, null), + Arguments.of(null, null) + ); + } + + private static ReviewCycle createReviewCycle() { + final Email email = Email.from("test@test.com"); + final Member member = Member.withoutId(email); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + return ReviewCycle.withoutId(review, LocalDateTime.now()); + } +} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java index c168b61..176fa6d 100644 --- a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java +++ b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java @@ -23,15 +23,13 @@ void withoutId() { final Member member = Member.withoutId(email); final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); final LocalDateTime scheduledAt = LocalDateTime.now(); - final NotificationStatus status = NotificationStatus.PENDING; // when - final ReviewCycle actual = ReviewCycle.withoutId(review, scheduledAt, status); + final ReviewCycle actual = ReviewCycle.withoutId(review, scheduledAt); // then assertThat(actual.getReview()).isEqualTo(review); assertThat(actual.getScheduledAt()).isEqualTo(scheduledAt); - assertThat(actual.getStatus()).isEqualTo(status); } @ParameterizedTest @@ -39,13 +37,12 @@ void withoutId() { @DisplayName("null로 생성 시도 시, 예외를 던진다") void throwExceptionWhenNull( final Review review, - final LocalDateTime scheduledAt, - final NotificationStatus status + final LocalDateTime scheduledAt ) { // given // when // then - assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt, status)) + assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt)) .isInstanceOf(IllegalArgumentException.class); } @@ -54,13 +51,11 @@ private static Stream provideInvalidValue() { final Member member = Member.withoutId(email); final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); final LocalDateTime scheduledAt = LocalDateTime.now(); - final NotificationStatus status = NotificationStatus.PENDING; return Stream.of( - Arguments.of(null, scheduledAt, status), - Arguments.of(review, null, status), - Arguments.of(review, scheduledAt, null), - Arguments.of(null, null, null) + Arguments.of(null, scheduledAt), + Arguments.of(review, null), + Arguments.of(null, null) ); } } diff --git a/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java b/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java new file mode 100644 index 0000000..a0f3dd6 --- /dev/null +++ b/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java @@ -0,0 +1,85 @@ +package com.recyclestudy.review.service; + +import com.recyclestudy.member.domain.Email; +import com.recyclestudy.member.domain.Member; +import com.recyclestudy.review.domain.NotificationHistory; +import com.recyclestudy.review.domain.NotificationStatus; +import com.recyclestudy.review.domain.Review; +import com.recyclestudy.review.domain.ReviewCycle; +import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.NotificationHistoryRepository; +import com.recyclestudy.review.repository.ReviewCycleRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationHistoryServiceTest { + + @Mock + NotificationHistoryRepository notificationHistoryRepository; + + @Mock + ReviewCycleRepository reviewCycleRepository; + + @InjectMocks + NotificationHistoryService notificationHistoryService; + + @Test + @DisplayName("ReviewCycle ID 목록으로 NotificationHistory를 저장한다") + void saveAll() { + // given + final List reviewCycleIds = List.of(1L, 2L); + final NotificationStatus status = NotificationStatus.SENT; + + final Member member = Member.withoutId(Email.from("test@test.com")); + final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review, LocalDateTime.now()); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review, LocalDateTime.now().plusDays(1)); + + given(reviewCycleRepository.findAllById(reviewCycleIds)).willReturn(List.of(cycle1, cycle2)); + + // when + notificationHistoryService.saveAll(reviewCycleIds, status); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + final List savedHistories = captor.getValue(); + assertSoftly(softAssertions -> { + softAssertions.assertThat(savedHistories).hasSize(2); + softAssertions.assertThat(savedHistories).allMatch(h -> h.getStatus() == NotificationStatus.SENT); + }); + } + + @Test + @DisplayName("빈 ID 목록이면 빈 NotificationHistory 목록을 저장한다") + void saveAll_emptyIds() { + // given + final List reviewCycleIds = List.of(); + final NotificationStatus status = NotificationStatus.SENT; + + given(reviewCycleRepository.findAllById(reviewCycleIds)).willReturn(List.of()); + + // when + notificationHistoryService.saveAll(reviewCycleIds, status); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + assertThat(captor.getValue()).isEmpty(); + } +} diff --git a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java index bb00414..cd912e2 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java @@ -2,7 +2,6 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; -import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; import com.recyclestudy.review.domain.ReviewURL; @@ -21,9 +20,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.assertj.core.api.SoftAssertions; - import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -48,7 +46,7 @@ void findTargetReviewCycle_success() { final Member member = Member.withoutId(Email.from("user@test.com")); final Review review = Review.withoutId(member, ReviewURL.from("https://example.com/article")); - final ReviewCycle reviewCycle = ReviewCycle.withoutId(review, scheduledAt, NotificationStatus.PENDING); + final ReviewCycle reviewCycle = ReviewCycle.withoutId(review, scheduledAt); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(reviewCycle)); @@ -56,7 +54,7 @@ void findTargetReviewCycle_success() { final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); // then - SoftAssertions.assertSoftly(softAssertions -> { + assertSoftly(softAssertions -> { softAssertions.assertThat(result.elements()).hasSize(1); softAssertions.assertThat(result.elements().getFirst().email()).isEqualTo(Email.from("user@test.com")); }); @@ -95,8 +93,8 @@ void findTargetReviewCycle_groupByEmail() { final Member member = Member.withoutId(Email.from("user@test.com")); final Review review1 = Review.withoutId(member, ReviewURL.from("https://example.com/article1")); final Review review2 = Review.withoutId(member, ReviewURL.from("https://example.com/article2")); - final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING); - final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); @@ -104,7 +102,7 @@ void findTargetReviewCycle_groupByEmail() { final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); // then - SoftAssertions.assertSoftly(softAssertions -> { + assertSoftly(softAssertions -> { softAssertions.assertThat(result.elements()).hasSize(1); final ReviewSendElement element = result.elements().getFirst(); @@ -127,8 +125,8 @@ void findTargetReviewCycle_multipleUsers() { final Member member2 = Member.withoutId(Email.from("user2@test.com")); final Review review1 = Review.withoutId(member1, ReviewURL.from("https://example.com/article1")); final Review review2 = Review.withoutId(member2, ReviewURL.from("https://example.com/article2")); - final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING); - final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt)).willReturn(List.of(cycle1, cycle2)); @@ -136,7 +134,7 @@ void findTargetReviewCycle_multipleUsers() { final ReviewSendOutput result = reviewCycleService.findTargetReviewCycle(input); // then - SoftAssertions.assertSoftly(softAssertions -> { + assertSoftly(softAssertions -> { softAssertions.assertThat(result.elements()).hasSize(2); final List emails = result.elements().stream() diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index 559db70..fb16a84 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -7,10 +7,12 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.DeviceRepository; +import com.recyclestudy.review.domain.NotificationHistory; import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; import com.recyclestudy.review.domain.ReviewURL; +import com.recyclestudy.review.repository.NotificationHistoryRepository; import com.recyclestudy.review.repository.ReviewCycleRepository; import com.recyclestudy.review.repository.ReviewRepository; import com.recyclestudy.review.service.input.ReviewSaveInput; @@ -25,13 +27,14 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -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.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; @@ -49,6 +52,9 @@ class ReviewServiceTest { @Mock DeviceRepository deviceRepository; + @Mock + NotificationHistoryRepository notificationHistoryRepository; + @Spy Clock clock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneId.of("UTC")); @@ -80,7 +86,7 @@ void saveReview() { final Email email = Email.from("test@test.com"); final Member member = Member.withoutId(email); final Review review = Review.withoutId(member, ReviewURL.from(urlValue)); - final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1), NotificationStatus.PENDING); + final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1)); given(deviceRepository.findByIdentifier(any())).willReturn(Optional.of(device)); given(reviewRepository.save(any(Review.class))).willReturn(review); @@ -90,8 +96,15 @@ void saveReview() { final ReviewSaveOutput actual = reviewService.saveReview(input); // then - assertThat(actual.url()).isEqualTo(ReviewURL.from(urlValue)); - assertThat(actual.scheduledAts()).hasSize(1); + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationHistoryRepository).saveAll(captor.capture()); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.url()).isEqualTo(ReviewURL.from(urlValue)); + softAssertions.assertThat(actual.scheduledAts()).hasSize(1); + softAssertions.assertThat(captor.getValue()).allMatch(h -> h.getStatus() == NotificationStatus.PENDING); + }); + verify(deviceRepository).findByIdentifier(any()); verify(reviewRepository).save(any(Review.class)); verify(reviewCycleRepository).saveAll(anyList()); @@ -104,7 +117,8 @@ void saveReview_fail_notFoundDevice() { final ReviewSaveInput input = ReviewSaveInput.of("not-found", "https://test.com"); given(deviceRepository.findByIdentifier(any())).willReturn(Optional.empty()); - // when & then + // when + // then assertThatThrownBy(() -> reviewService.saveReview(input)) .isInstanceOf(UnauthorizedException.class) .hasMessage("유효하지 않은 디바이스입니다"); @@ -126,7 +140,8 @@ void saveReview_fail_inactiveDevice() { given(deviceRepository.findByIdentifier(any())).willReturn(Optional.of(inactiveDevice)); - // when & then + // when + // then assertThatThrownBy(() -> reviewService.saveReview(input)) .isInstanceOf(UnauthorizedException.class) .hasMessage("인증되지 않은 디바이스입니다"); From 42952edbccb86f4c99153e20883ab5bb27e1b6e4 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 30 Dec 2025 18:32:43 +0900 Subject: [PATCH 7/9] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ActivationExpiredDateTime.java | 4 +-- .../recyclestudy/member/domain/Device.java | 28 +++++++-------- .../member/domain/DeviceIdentifier.java | 4 +-- .../com/recyclestudy/member/domain/Email.java | 4 +-- .../recyclestudy/member/domain/Member.java | 8 ++--- .../review/domain/NotificationHistory.java | 16 ++++----- .../recyclestudy/review/domain/Review.java | 16 ++++----- .../review/domain/ReviewCycle.java | 12 +++---- src/main/resources/templates/auth_email.html | 4 +-- .../resources/templates/auth_success.html | 2 +- .../resources/templates/review_email.html | 4 +-- .../RecyclestudyApplicationTests.java | 6 ++-- .../email/DeviceAuthEmailSenderTest.java | 3 +- .../member/domain/DeviceTest.java | 30 ++++++++-------- .../recyclestudy/restdocs/APIBaseTest.java | 2 +- .../domain/NotificationHistoryTest.java | 36 +++++++++---------- .../review/domain/ReviewCycleTest.java | 26 +++++++------- .../review/domain/ReviewTest.java | 22 ++++++------ 18 files changed, 114 insertions(+), 113 deletions(-) diff --git a/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java index 72bc023..08b1028 100644 --- a/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java +++ b/src/main/java/com/recyclestudy/member/domain/ActivationExpiredDateTime.java @@ -24,6 +24,8 @@ public class ActivationExpiredDateTime { private static final Duration EXPIRE_TIME_RATE = Duration.ofMinutes(5); + private LocalDateTime value; + public static ActivationExpiredDateTime create(final LocalDateTime currentTime) { validateNotNull(currentTime); return new ActivationExpiredDateTime(currentTime.plusMinutes(EXPIRE_TIME_RATE.toMinutes())); @@ -35,8 +37,6 @@ private static void validateNotNull(final LocalDateTime currentTime) { .validate(); } - private LocalDateTime value; - public void checkExpired(final LocalDateTime currentTime) { if (currentTime.isAfter(value)) { throw new DeviceActivationExpiredException("인증 유효 시간이 만료되었습니다."); diff --git a/src/main/java/com/recyclestudy/member/domain/Device.java b/src/main/java/com/recyclestudy/member/domain/Device.java index 4e0560d..6749bd5 100644 --- a/src/main/java/com/recyclestudy/member/domain/Device.java +++ b/src/main/java/com/recyclestudy/member/domain/Device.java @@ -26,20 +26,6 @@ @Getter public class Device extends BaseEntity { - public static Device withoutId( - final Member member, - final DeviceIdentifier deviceIdentifier, - final boolean isActive, - final ActivationExpiredDateTime activationExpiresAt - ) { - NullValidator.builder() - .add(Fields.member, member) - .add(Fields.identifier, deviceIdentifier) - .add(Fields.activationExpiresAt, activationExpiresAt) - .validate(); - return new Device(member, deviceIdentifier, isActive, activationExpiresAt); - } - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; @@ -55,6 +41,20 @@ public static Device withoutId( @AttributeOverride(name = "value", column = @Column(name = "activation_expires_at", nullable = false)) private ActivationExpiredDateTime activationExpiresAt; + public static Device withoutId( + final Member member, + final DeviceIdentifier deviceIdentifier, + final boolean isActive, + final ActivationExpiredDateTime activationExpiresAt + ) { + NullValidator.builder() + .add(Fields.member, member) + .add(Fields.identifier, deviceIdentifier) + .add(Fields.activationExpiresAt, activationExpiresAt) + .validate(); + return new Device(member, deviceIdentifier, isActive, activationExpiresAt); + } + public void activate(final LocalDateTime currentTime) { activationExpiresAt.checkExpired(currentTime); this.isActive = true; diff --git a/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java index d031160..8e6e488 100644 --- a/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java +++ b/src/main/java/com/recyclestudy/member/domain/DeviceIdentifier.java @@ -19,6 +19,8 @@ @EqualsAndHashCode public class DeviceIdentifier { + private String value; + public static DeviceIdentifier from(final String value) { validateNotNull(value); return new DeviceIdentifier(value); @@ -34,6 +36,4 @@ private static void validateNotNull(final String value) { .add(Fields.value, value) .validate(); } - - private String value; } diff --git a/src/main/java/com/recyclestudy/member/domain/Email.java b/src/main/java/com/recyclestudy/member/domain/Email.java index c5fd3cd..6c9dddb 100644 --- a/src/main/java/com/recyclestudy/member/domain/Email.java +++ b/src/main/java/com/recyclestudy/member/domain/Email.java @@ -23,6 +23,8 @@ public class Email { private static final String EMAIL_FORMAT = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_FORMAT); + private String value; + public static Email from(final String value) { validateNotNull(value); validateEmailFormat(value); @@ -40,6 +42,4 @@ private static void validateEmailFormat(final String emailValue) { throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다."); } } - - private String value; } diff --git a/src/main/java/com/recyclestudy/member/domain/Member.java b/src/main/java/com/recyclestudy/member/domain/Member.java index 9dee912..b994ca1 100644 --- a/src/main/java/com/recyclestudy/member/domain/Member.java +++ b/src/main/java/com/recyclestudy/member/domain/Member.java @@ -21,6 +21,10 @@ @Getter public class Member extends BaseEntity { + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) + private Email email; + public static Member withoutId(final Email email) { validateNotNull(email); return new Member(email); @@ -32,10 +36,6 @@ private static void validateNotNull(final Email email) { .validate(); } - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, unique = true)) - private Email email; - public boolean hasEmail(final Email email) { return this.email.equals(email); } diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java index 350a83f..500dc5d 100644 --- a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java +++ b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java @@ -24,6 +24,14 @@ @Getter public class NotificationHistory extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_cycle_id", nullable = false) + private ReviewCycle reviewCycle; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private NotificationStatus status; + public static NotificationHistory withoutId( final ReviewCycle reviewCycle, final NotificationStatus status @@ -41,12 +49,4 @@ private static void validateNotNull( .add(Fields.status, status) .validate(); } - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "review_cycle_id", nullable = false) - private ReviewCycle reviewCycle; - - @Enumerated(value = EnumType.STRING) - @Column(name = "status", nullable = false) - private NotificationStatus status; } diff --git a/src/main/java/com/recyclestudy/review/domain/Review.java b/src/main/java/com/recyclestudy/review/domain/Review.java index c0488bf..a21b72c 100644 --- a/src/main/java/com/recyclestudy/review/domain/Review.java +++ b/src/main/java/com/recyclestudy/review/domain/Review.java @@ -25,6 +25,14 @@ @Getter public class Review extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) + private ReviewURL url; + public static Review withoutId(final Member member, final ReviewURL url) { validateNotNull(member, url); return new Review(member, url); @@ -36,12 +44,4 @@ private static void validateNotNull(final Member member, final ReviewURL url) { .add(Fields.url, url) .validate(); } - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false) - private Member member; - - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "url", nullable = false, columnDefinition = "TEXT")) - private ReviewURL url; } diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java index 1f0bd95..d4017b9 100644 --- a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java @@ -22,6 +22,12 @@ @Getter public class ReviewCycle extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + private LocalDateTime scheduledAt; + public static ReviewCycle withoutId(final Review review, final LocalDateTime scheduledAt) { validateNotNull(review, scheduledAt); return new ReviewCycle(review, scheduledAt); @@ -33,10 +39,4 @@ private static void validateNotNull(final Review review, final LocalDateTime sch .add(Fields.scheduledAt, scheduledAt) .validate(); } - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "review_id", nullable = false) - private Review review; - - private LocalDateTime scheduledAt; } diff --git a/src/main/resources/templates/auth_email.html b/src/main/resources/templates/auth_email.html index c7648c8..5a2aa4d 100644 --- a/src/main/resources/templates/auth_email.html +++ b/src/main/resources/templates/auth_email.html @@ -6,8 +6,8 @@

안녕하세요! 이메일 인증을 완료해주세요.

아래 버튼을 클릭하여 디바이스 인증을 완료하세요.

- + 인증하기 diff --git a/src/main/resources/templates/auth_success.html b/src/main/resources/templates/auth_success.html index 9019729..65639fa 100644 --- a/src/main/resources/templates/auth_success.html +++ b/src/main/resources/templates/auth_success.html @@ -2,7 +2,7 @@ - + 이메일 인증 완료