diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index d1d50e7..872b5ec 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -1,7 +1,13 @@ name: "Claude Code Review" + on: pull_request: types: [ opened, reopened, synchronize ] + +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: review: runs-on: ubuntu-latest @@ -48,6 +54,9 @@ jobs: author { login } + reactions { + totalCount + } } } } @@ -55,7 +64,7 @@ jobs: } } }' \ - --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | select([.comments.nodes[].author.login] | all(. == "github-actions")) | .comments.nodes[].databaseId' \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | select([.comments.nodes[].author.login] | all(. == "github-actions")) | select([.comments.nodes[].reactions.totalCount] | add == 0) | .comments.nodes[].databaseId' \ | while read comment_id; do if [ -n "$comment_id" ]; then echo "๐Ÿ—‘๏ธ ์ธ๋ผ์ธ ์ฝ”๋ฉ˜ํŠธ ์‚ญ์ œ: $comment_id" diff --git a/.github/workflows/comment-response.yml b/.github/workflows/comment-response.yml new file mode 100644 index 0000000..d8f2ea7 --- /dev/null +++ b/.github/workflows/comment-response.yml @@ -0,0 +1,31 @@ +name: Claude Issue & PR Comment Response + +on: + issue_comment: + types: [ created ] + pull_request_review_comment: + types: [ created ] + +concurrency: + group: claude-comment-${{ github.event.issue.number }}-${{ github.event.comment.id }} + cancel-in-progress: false + +jobs: + respond: + if: | + github.event.comment.user.login != 'github-actions[bot]' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'COLLABORATOR') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index e97eaad..23dd6b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,4 @@ USER appuser EXPOSE 8080 -ENTRYPOINT ["java", "-Duser.timezone=UTC", "-jar", "app.jar"] +ENTRYPOINT ["java", "-Duser.timezone=UTC", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=60.0", "-XX:InitialRAMPercentage=60.0", "-XX:MaxMetaspaceSize=150m", "-XX:+ExitOnOutOfMemoryError", "-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=/app/log/heapdump.hprof", "-jar", "app.jar"] diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 00718ac..ca903ee 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -9,6 +9,10 @@ services: - .env volumes: - /app/log:/app/log + deploy: + resources: + limits: + memory: 600m networks: - observability @@ -30,6 +34,10 @@ services: ports: - "12345:12345" # Alloy UI - "4317:4317" # OTLP gRPC + deploy: + resources: + limits: + memory: 150m networks: - observability @@ -37,6 +45,10 @@ services: image: quay.io/prometheus/node-exporter:latest container_name: node-exporter restart: unless-stopped + deploy: + resources: + limits: + memory: 30m networks: - observability diff --git a/src/main/java/com/recyclestudy/email/SingleReviewEmailSender.java b/src/main/java/com/recyclestudy/email/SingleReviewEmailSender.java index ca50e7a..fdb183e 100644 --- a/src/main/java/com/recyclestudy/email/SingleReviewEmailSender.java +++ b/src/main/java/com/recyclestudy/email/SingleReviewEmailSender.java @@ -3,7 +3,7 @@ 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.output.ReviewSendOutput.ReviewSendElement; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,7 +20,7 @@ public class SingleReviewEmailSender { private final EmailSender emailSender; private final TemplateEngine templateEngine; - private final NotificationHistoryService notificationHistoryService; + private final ReviewCycleService reviewCycleService; @Async public void sendOne(final ReviewSendElement element) { @@ -30,10 +30,10 @@ public void sendOne(final ReviewSendElement element) { final boolean success = sendToTargetEmail(targetEmail, message); if (success) { - notificationHistoryService.updateStatus(element.reviewCycleIds(), NotificationStatus.SENT); + reviewCycleService.updateStatus(element.reviewCycleIds(), NotificationStatus.SENT); return; } - notificationHistoryService.updateStatus(element.reviewCycleIds(), NotificationStatus.FAILED); + reviewCycleService.updateStatus(element.reviewCycleIds(), NotificationStatus.FAILED); } private boolean sendToTargetEmail(final Email targetEmail, final String message) { diff --git a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java b/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java deleted file mode 100644 index 1d1888b..0000000 --- a/src/main/java/com/recyclestudy/review/domain/NotificationHistory.java +++ /dev/null @@ -1,72 +0,0 @@ -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 jakarta.persistence.UniqueConstraint; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.FieldNameConstants; - -@Entity -@Table( - name = "notification_history", - uniqueConstraints = @UniqueConstraint( - name = "uk_notification_history_review_cycle_id", - columnNames = "review_cycle_id" - ) -) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@FieldNameConstants(level = AccessLevel.PRIVATE) -@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; - - @Column(name = "fail_count", nullable = false) - private int failCount; - - @Column(name = "last_attempted_at") - private LocalDateTime lastAttemptedAt; - - @Column(name = "deadline", nullable = false) - private LocalDateTime deadline; - - public static NotificationHistory withoutId( - final ReviewCycle reviewCycle, - final NotificationStatus status, - final LocalDateTime deadline - ) { - validateNotNull(reviewCycle, status, deadline); - return new NotificationHistory(reviewCycle, status, 0, null, deadline); - } - - private static void validateNotNull( - final ReviewCycle reviewCycle, - final NotificationStatus status, - final LocalDateTime deadline - ) { - NullValidator.builder() - .add(Fields.reviewCycle, reviewCycle) - .add(Fields.status, status) - .add(Fields.deadline, deadline) - .validate(); - } -} diff --git a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java index a320d83..437e9a1 100644 --- a/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java +++ b/src/main/java/com/recyclestudy/review/domain/ReviewCycle.java @@ -4,6 +4,8 @@ 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; @@ -30,15 +32,40 @@ public class ReviewCycle extends BaseEntity { @Column(name = "scheduled_at", nullable = false) private LocalDateTime scheduledAt; - public static ReviewCycle withoutId(final Review review, final LocalDateTime scheduledAt) { - validateNotNull(review, scheduledAt); - return new ReviewCycle(review, scheduledAt); + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private NotificationStatus status; + + @Column(name = "fail_count", nullable = false) + private int failCount; + + @Column(name = "last_attempted_at") + private LocalDateTime lastAttemptedAt; + + @Column(name = "deadline", nullable = false) + private LocalDateTime deadline; + + public static ReviewCycle withoutId( + final Review review, + final LocalDateTime scheduledAt, + final NotificationStatus status, + final LocalDateTime deadline + ) { + validateNotNull(review, scheduledAt, status, deadline); + return new ReviewCycle(review, scheduledAt, status, 0, null, deadline); } - private static void validateNotNull(final Review review, final LocalDateTime scheduledAt) { + private static void validateNotNull( + final Review review, + final LocalDateTime scheduledAt, + final NotificationStatus status, + final LocalDateTime deadline + ) { NullValidator.builder() .add(Fields.review, review) .add(Fields.scheduledAt, scheduledAt) + .add(Fields.status, status) + .add(Fields.deadline, deadline) .validate(); } } diff --git a/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java b/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java deleted file mode 100644 index 037f8be..0000000 --- a/src/main/java/com/recyclestudy/review/repository/NotificationHistoryRepository.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.recyclestudy.review.repository; - -import com.recyclestudy.review.domain.NotificationHistory; -import com.recyclestudy.review.domain.NotificationStatus; -import java.time.LocalDateTime; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface NotificationHistoryRepository extends JpaRepository { - - @Modifying(clearAutomatically = true) - @Query(""" - UPDATE NotificationHistory nh - SET nh.status = :status, nh.lastAttemptedAt = :now - WHERE nh.reviewCycle.id IN :reviewCycleIds - """) - int updateStatus( - @Param("reviewCycleIds") List reviewCycleIds, - @Param("status") NotificationStatus status, - @Param("now") LocalDateTime now - ); - - @Modifying(clearAutomatically = true) - @Query(""" - UPDATE NotificationHistory nh - SET nh.status = :status, nh.failCount = nh.failCount + 1, nh.lastAttemptedAt = :now - WHERE nh.reviewCycle.id IN :reviewCycleIds - """) - int updateStatusAndIncrementFailCount( - @Param("reviewCycleIds") List reviewCycleIds, - @Param("status") NotificationStatus status, - @Param("now") LocalDateTime now - ); - - @Query(""" - SELECT nh FROM NotificationHistory nh - JOIN FETCH nh.reviewCycle rc - WHERE rc.review.member.id = :memberId - AND nh.status = :status - ORDER BY rc.scheduledAt ASC - """) - List findAllByMemberAndStatus( - @Param("memberId") Long memberId, - @Param("status") NotificationStatus status - ); -} diff --git a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java index 0074755..470467d 100644 --- a/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java +++ b/src/main/java/com/recyclestudy/review/repository/ReviewCycleRepository.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,9 +16,8 @@ public interface ReviewCycleRepository extends JpaRepository SELECT rc FROM ReviewCycle rc JOIN FETCH rc.review r JOIN FETCH r.member - JOIN NotificationHistory nh ON rc.id = nh.reviewCycle.id WHERE rc.scheduledAt <= :scheduledAt - AND nh.status = :status + AND rc.status = :status """) List findAllByScheduledAt( @Param("scheduledAt") LocalDateTime scheduledAt, @@ -28,13 +28,48 @@ List findAllByScheduledAt( SELECT rc FROM ReviewCycle rc JOIN FETCH rc.review r JOIN FETCH r.member - JOIN NotificationHistory nh ON rc.id = nh.reviewCycle.id - WHERE nh.status = :status - AND nh.deadline > :now + WHERE rc.status = :status + AND rc.deadline > :now """) List findAllRetryableCycles( @Param("status") NotificationStatus status, @Param("now") LocalDateTime now ); + @Query(""" + SELECT rc FROM ReviewCycle rc + JOIN FETCH rc.review r + JOIN FETCH r.member + WHERE r.member.id = :memberId + AND rc.status = :status + ORDER BY rc.scheduledAt ASC + """) + List findAllByMemberAndStatus( + @Param("memberId") Long memberId, + @Param("status") NotificationStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ReviewCycle rc + SET rc.status = :status, rc.lastAttemptedAt = :now + WHERE rc.id IN :ids + """) + int updateStatus( + @Param("ids") List ids, + @Param("status") NotificationStatus status, + @Param("now") LocalDateTime now + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ReviewCycle rc + SET rc.status = :status, rc.failCount = rc.failCount + 1, rc.lastAttemptedAt = :now + WHERE rc.id IN :ids + """) + int updateStatusAndIncrementFailCount( + @Param("ids") List ids, + @Param("status") NotificationStatus status, + @Param("now") LocalDateTime now + ); } diff --git a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java deleted file mode 100644 index ca2b74a..0000000 --- a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.recyclestudy.review.service; - -import com.recyclestudy.review.domain.NotificationStatus; -import com.recyclestudy.review.repository.NotificationHistoryRepository; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class NotificationHistoryService { - - private final NotificationHistoryRepository notificationHistoryRepository; - private final Clock clock; - - @Transactional - public void updateStatus(final List reviewCycleIds, final NotificationStatus status) { - if (reviewCycleIds.isEmpty()) { - return; - } - final LocalDateTime now = LocalDateTime.now(clock); - final int updated; - if (status == NotificationStatus.FAILED) { - updated = notificationHistoryRepository.updateStatusAndIncrementFailCount(reviewCycleIds, status, now); - } else { - updated = notificationHistoryRepository.updateStatus(reviewCycleIds, status, now); - } - if (updated != reviewCycleIds.size()) { - log.warn("[NOTIFY_HIST_MISMATCH] ๊ธฐ๋Œ€={}, ์‹ค์ œ={}", reviewCycleIds.size(), updated); - } - log.info("[NOTIFY_HIST_UPDATED] ์•Œ๋ฆผ ์ด๋ ฅ ์ƒํƒœ ๋ณ€๊ฒฝ: status={}, count={}", status, updated); - } -} diff --git a/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java index c962bb1..67da0db 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewCycleService.java @@ -3,28 +3,29 @@ import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -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 com.recyclestudy.review.service.input.NextReviewInput; import com.recyclestudy.review.service.input.ReviewSendInput; import com.recyclestudy.review.service.output.NextReviewOutput; import com.recyclestudy.review.service.output.ReviewSendOutput; +import java.time.Clock; import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class ReviewCycleService { private final ReviewCycleRepository reviewCycleRepository; private final MemberRepository memberRepository; - private final NotificationHistoryRepository notificationHistoryRepository; + private final Clock clock; @Transactional(readOnly = true) public ReviewSendOutput findTargetReviewCycle(final ReviewSendInput input) { @@ -38,18 +39,36 @@ public NextReviewOutput findNextReview(final NextReviewInput input) { final Member member = memberRepository.findByIdentifier(input.identifier()) .orElseThrow(() -> new UnauthorizedException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋””๋ฐ”์ด์Šค์ž…๋‹ˆ๋‹ค")); - final List allPending = notificationHistoryRepository + final List allPending = reviewCycleRepository .findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING); if (allPending.isEmpty()) { return NextReviewOutput.empty(); } - final LocalDateTime earliest = allPending.getFirst().getReviewCycle().getScheduledAt(); + final LocalDateTime earliest = allPending.getFirst().getScheduledAt(); final int count = (int) allPending.stream() - .takeWhile(nh -> nh.getReviewCycle().getScheduledAt().equals(earliest)) + .takeWhile(rc -> rc.getScheduledAt().equals(earliest)) .count(); return NextReviewOutput.of(earliest, count); } + + @Transactional + public void updateStatus(final List reviewCycleIds, final NotificationStatus status) { + if (reviewCycleIds.isEmpty()) { + return; + } + final LocalDateTime now = LocalDateTime.now(clock); + final int updated; + if (status == NotificationStatus.FAILED) { + updated = reviewCycleRepository.updateStatusAndIncrementFailCount(reviewCycleIds, status, now); + } else { + updated = reviewCycleRepository.updateStatus(reviewCycleIds, status, now); + } + if (updated != reviewCycleIds.size()) { + log.warn("[RC_STATUS_MISMATCH] ๊ธฐ๋Œ€={}, ์‹ค์ œ={}", reviewCycleIds.size(), updated); + } + log.info("[RC_STATUS_UPDATED] ์ƒํƒœ ๋ณ€๊ฒฝ: status={}, count={}", status, updated); + } } diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 1b025d9..15f8d89 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -10,11 +10,9 @@ import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -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.repository.NotificationHistoryRepository; import com.recyclestudy.review.repository.ReviewCycleRepository; import com.recyclestudy.review.repository.ReviewRepository; import com.recyclestudy.review.service.input.ReviewSaveInput; @@ -42,7 +40,6 @@ public class ReviewService { private final MemberRepository memberRepository; private final CycleOptionRepository cycleOptionRepository; private final CycleSelectionResolverRegistry cycleSelectionResolverRegistry; - private final NotificationHistoryRepository notificationHistoryRepository; private final Clock clock; @Transactional @@ -57,8 +54,14 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { validateCycleSelectionOwnership(input.cycle(), member); final List scheduledAts = calculateScheduledAts(input.cycle(), member); - final List reviewCycles = scheduledAts.stream() - .map(scheduledAt -> ReviewCycle.withoutId(savedReview, scheduledAt)) + final List reviewCycles = IntStream.range(0, scheduledAts.size()) + .mapToObj(i -> { + final LocalDateTime scheduledAt = scheduledAts.get(i); + final LocalDateTime deadline = (i < scheduledAts.size() - 1) + ? scheduledAts.get(i + 1) + : scheduledAt.plusHours(LAST_CYCLE_DEADLINE_HOURS); + return ReviewCycle.withoutId(savedReview, scheduledAt, NotificationStatus.PENDING, deadline); + }) .toList(); final List savedReviewCycles = reviewCycleRepository.saveAll(reviewCycles); @@ -68,8 +71,6 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { log.info("[REVIEW_CYCLE_SAVED] ๋ณต์Šต ์ฃผ๊ธฐ ์ €์žฅ ์„ฑ๊ณต: reviewCycleId={}", savedReviewCycles.stream().map(BaseEntity::getId).toList()); - savePendingNotificationHistory(savedReviewCycles); - return ReviewSaveOutput.of(savedReview.getUrl(), savedScheduledAts); } @@ -98,22 +99,6 @@ private LocalDateTime calculateScheduledAt( return adjustedTime; } - private void savePendingNotificationHistory(final List savedReviewCycles) { - final List notificationHistories = IntStream.range(0, savedReviewCycles.size()) - .mapToObj(i -> { - final ReviewCycle reviewCycle = savedReviewCycles.get(i); - final LocalDateTime deadline = (i < savedReviewCycles.size() - 1) - ? savedReviewCycles.get(i + 1).getScheduledAt() - : reviewCycle.getScheduledAt().plusHours(LAST_CYCLE_DEADLINE_HOURS); - return NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING, deadline); - }) - .toList(); - final List savedNotificationHistories - = notificationHistoryRepository.saveAll(notificationHistories); - log.info("[NOTIFY_HIST_SAVED] ์ „์†ก ํ˜„ํ™ฉ ๋“ฑ๋ก ์„ฑ๊ณต: status={}, notificationHistoryId={}", - NotificationStatus.PENDING, savedNotificationHistories.stream().map(BaseEntity::getId).toList()); - } - private void validateCycleSelectionOwnership(final CycleSelection cycleSelection, final Member member) { if (!(cycleSelection instanceof CustomCycleSelection(Long id))) { return; diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index a2fff2a..adca318 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,4 +1,7 @@ spring: + datasource: + hikari: + leak-detection-threshold: 5000 jpa: properties: hibernate: diff --git a/src/main/resources/db/migration/V20260407_1__add_index_notification_history_status_deadline.sql b/src/main/resources/db/migration/V20260407_1__add_index_notification_history_status_deadline.sql new file mode 100644 index 0000000..0384714 --- /dev/null +++ b/src/main/resources/db/migration/V20260407_1__add_index_notification_history_status_deadline.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_nh_status_deadline + ON notification_history (status, deadline); diff --git a/src/main/resources/db/migration/V20260524_1__merge_notification_history_into_review_cycle.sql b/src/main/resources/db/migration/V20260524_1__merge_notification_history_into_review_cycle.sql new file mode 100644 index 0000000..956abe6 --- /dev/null +++ b/src/main/resources/db/migration/V20260524_1__merge_notification_history_into_review_cycle.sql @@ -0,0 +1,32 @@ +-- 1. review_cycle์— ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (NULL ํ—ˆ์šฉ์œผ๋กœ ์‹œ์ž‘, ๋ฐ์ดํ„ฐ ์ด์ „ ํ›„ ์ œ์•ฝ ์ถ”๊ฐ€) +ALTER TABLE review_cycle + ADD COLUMN status ENUM('FAILED', 'PENDING', 'SENT') NULL, + ADD COLUMN fail_count INT NULL, + ADD COLUMN last_attempted_at DATETIME NULL, + ADD COLUMN deadline DATETIME NULL; + +-- 2. notification_history โ†’ review_cycle ๋ฐ์ดํ„ฐ ์ด์ „ +UPDATE review_cycle rc + INNER JOIN notification_history nh ON nh.review_cycle_id = rc.id +SET rc.status = nh.status, + rc.fail_count = nh.fail_count, + rc.last_attempted_at = nh.last_attempted_at, + rc.deadline = nh.deadline; + +-- 3. NOT NULL ์ œ์•ฝ ์ถ”๊ฐ€ +ALTER TABLE review_cycle + MODIFY COLUMN status ENUM('FAILED', 'PENDING', 'SENT') NOT NULL, + MODIFY COLUMN fail_count INT NOT NULL DEFAULT 0, + MODIFY COLUMN deadline DATETIME NOT NULL; + +-- 4. ๊ธฐ์กด ๋‹จ์ผ ์ธ๋ฑ์Šค ์‚ญ์ œ +DROP INDEX idx_review_cycle_scheduled_at ON review_cycle; + +-- 5. ์ตœ์  ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ +-- ์ด๋ฉ”์ผ ๋ฐœ์†ก ์Šค์ผ€์ค„๋Ÿฌ: status = PENDING (๋™๋“ฑ) + scheduled_at <= ? (๋ฒ”์œ„) +CREATE INDEX idx_rc_status_scheduled_at ON review_cycle (status, scheduled_at); +-- ์žฌ์‹œ๋„ ์Šค์ผ€์ค„๋Ÿฌ: status = FAILED (๋™๋“ฑ) + deadline > ? (๋ฒ”์œ„) +CREATE INDEX idx_rc_status_deadline ON review_cycle (status, deadline); + +-- โ€ป notification_history ํ…Œ์ด๋ธ” DROP์€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์— ํฌํ•จํ•˜์ง€ ์•Š์Œ. +-- ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์™„๋ฃŒ ํ›„ ๋ณ„๋„๋กœ ์ง์ ‘ ์ˆ˜ํ–‰ํ•  ์˜ˆ์ •. diff --git a/src/test/java/com/recyclestudy/email/SingleReviewEmailSenderTest.java b/src/test/java/com/recyclestudy/email/SingleReviewEmailSenderTest.java index 368c9cb..93876ea 100644 --- a/src/test/java/com/recyclestudy/email/SingleReviewEmailSenderTest.java +++ b/src/test/java/com/recyclestudy/email/SingleReviewEmailSenderTest.java @@ -3,7 +3,7 @@ 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.output.ReviewSendOutput.ReviewSendElement; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -34,7 +34,7 @@ class SingleReviewEmailSenderTest { private TemplateEngine templateEngine; @Mock - private NotificationHistoryService notificationHistoryService; + private ReviewCycleService reviewCycleService; @InjectMocks private SingleReviewEmailSender singleReviewEmailSender; @@ -55,7 +55,7 @@ void sendOne_success() { // then verify(emailSender).send(eq(email), eq("[Recycle Study] ์˜ค๋Š˜์˜ ๋ณต์Šต ๋ชฉ๋ก์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค"), any()); - verify(notificationHistoryService).updateStatus(ids, NotificationStatus.SENT); + verify(reviewCycleService).updateStatus(ids, NotificationStatus.SENT); } @Test @@ -73,7 +73,7 @@ void sendOne_failure() { singleReviewEmailSender.sendOne(element); // then - verify(notificationHistoryService).updateStatus(ids, NotificationStatus.FAILED); + verify(reviewCycleService).updateStatus(ids, NotificationStatus.FAILED); } @Test diff --git a/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java b/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java deleted file mode 100644 index 5e68883..0000000 --- a/src/test/java/com/recyclestudy/review/domain/NotificationHistoryTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.recyclestudy.review.domain; - -import com.recyclestudy.member.domain.Email; -import com.recyclestudy.member.domain.Member; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -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 { - - private static Stream provideInvalidValue() { - final ReviewCycle reviewCycle = createReviewCycle(); - final NotificationStatus status = NotificationStatus.PENDING; - final LocalDateTime deadline = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).plusHours(24); - - return Stream.of( - Arguments.of(null, status, deadline), - Arguments.of(reviewCycle, null, deadline), - Arguments.of(reviewCycle, status, null), - Arguments.of(null, 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().truncatedTo(ChronoUnit.MINUTES)); - } - - @Test - @DisplayName("NotificationHistory๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") - void withoutId() { - // given - final ReviewCycle reviewCycle = createReviewCycle(); - final NotificationStatus status = NotificationStatus.PENDING; - final LocalDateTime deadline = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).plusHours(24); - - // when - final NotificationHistory actual = NotificationHistory.withoutId(reviewCycle, status, deadline); - - // then - assertSoftly(softAssertions -> { - softAssertions.assertThat(actual.getReviewCycle()).isEqualTo(reviewCycle); - softAssertions.assertThat(actual.getStatus()).isEqualTo(status); - softAssertions.assertThat(actual.getDeadline()).isEqualTo(deadline); - }); - } - - @ParameterizedTest - @MethodSource("provideInvalidValue") - @DisplayName("null๋กœ ์ƒ์„ฑ ์‹œ๋„ ์‹œ, ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") - void throwExceptionWhenNull( - final ReviewCycle reviewCycle, - final NotificationStatus status, - final LocalDateTime deadline - ) { - // given - // when - // then - assertThatThrownBy(() -> NotificationHistory.withoutId(reviewCycle, status, deadline)) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java index 7dbdfdc..a624573 100644 --- a/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java +++ b/src/test/java/com/recyclestudy/review/domain/ReviewCycleTest.java @@ -11,8 +11,8 @@ 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; +import static org.assertj.core.api.SoftAssertions.assertSoftly; class ReviewCycleTest { @@ -21,11 +21,15 @@ 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().truncatedTo(ChronoUnit.MINUTES); + final NotificationStatus status = NotificationStatus.PENDING; + final LocalDateTime deadline = scheduledAt.plusHours(24); return Stream.of( - Arguments.of(null, scheduledAt), - Arguments.of(review, null), - Arguments.of(null, null) + Arguments.of(null, scheduledAt, status, deadline), + Arguments.of(review, null, status, deadline), + Arguments.of(review, scheduledAt, null, deadline), + Arguments.of(review, scheduledAt, status, null), + Arguments.of(null, null, null, null) ); } @@ -37,13 +41,21 @@ void withoutId() { final Member member = Member.withoutId(email); final Review review = Review.withoutId(member, ReviewURL.from("https://test.com")); final LocalDateTime scheduledAt = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); + final NotificationStatus status = NotificationStatus.PENDING; + final LocalDateTime deadline = scheduledAt.plusHours(24); // when - final ReviewCycle actual = ReviewCycle.withoutId(review, scheduledAt); + final ReviewCycle actual = ReviewCycle.withoutId(review, scheduledAt, status, deadline); // then - assertThat(actual.getReview()).isEqualTo(review); - assertThat(actual.getScheduledAt()).isEqualTo(scheduledAt); + assertSoftly(softly -> { + softly.assertThat(actual.getReview()).isEqualTo(review); + softly.assertThat(actual.getScheduledAt()).isEqualTo(scheduledAt); + softly.assertThat(actual.getStatus()).isEqualTo(status); + softly.assertThat(actual.getDeadline()).isEqualTo(deadline); + softly.assertThat(actual.getFailCount()).isEqualTo(0); + softly.assertThat(actual.getLastAttemptedAt()).isNull(); + }); } @ParameterizedTest @@ -51,12 +63,14 @@ void withoutId() { @DisplayName("null๋กœ ์ƒ์„ฑ ์‹œ๋„ ์‹œ, ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") void throwExceptionWhenNull( final Review review, - final LocalDateTime scheduledAt + final LocalDateTime scheduledAt, + final NotificationStatus status, + final LocalDateTime deadline ) { // given // when // then - assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt)) + assertThatThrownBy(() -> ReviewCycle.withoutId(review, scheduledAt, status, deadline)) .isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/com/recyclestudy/review/repository/NotificationHistoryRepositoryTest.java b/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryMemberStatusTest.java similarity index 55% rename from src/test/java/com/recyclestudy/review/repository/NotificationHistoryRepositoryTest.java rename to src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryMemberStatusTest.java index a690ec9..a2d5eed 100644 --- a/src/test/java/com/recyclestudy/review/repository/NotificationHistoryRepositoryTest.java +++ b/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryMemberStatusTest.java @@ -3,7 +3,6 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -import com.recyclestudy.review.domain.NotificationHistory; import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; @@ -19,10 +18,7 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; @DataJpaTest -class NotificationHistoryRepositoryTest { - - @Autowired - private NotificationHistoryRepository notificationHistoryRepository; +class ReviewCycleRepositoryMemberStatusTest { @Autowired private ReviewCycleRepository reviewCycleRepository; @@ -37,53 +33,53 @@ class NotificationHistoryRepositoryTest { private static final LocalDateTime T2 = LocalDateTime.of(2026, 3, 7, 9, 0); @Test - @DisplayName("๋ณธ์ธ์˜ PENDING NotificationHistory๊ฐ€ scheduledAt ASC ์ˆœ์œผ๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค") - void findAllPendingByMember_AscOrderAndStatus() { + @DisplayName("๋ณธ์ธ์˜ PENDING ReviewCycle์ด scheduledAt ASC ์ˆœ์œผ๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค") + void findAllByMemberAndStatus_AscOrderAndStatus() { // given final Member member = saveMember("user@test.com"); - saveNotificationHistory(member, T2, NotificationStatus.PENDING); - saveNotificationHistory(member, T1, NotificationStatus.PENDING); + saveReviewCycle(member, T2, NotificationStatus.PENDING); + saveReviewCycle(member, T1, NotificationStatus.PENDING); // when - final List result = notificationHistoryRepository + final List result = reviewCycleRepository .findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING); // then assertSoftly(softly -> { softly.assertThat(result).hasSize(2); - softly.assertThat(result.get(0).getReviewCycle().getScheduledAt()).isEqualTo(T1); - softly.assertThat(result.get(1).getReviewCycle().getScheduledAt()).isEqualTo(T2); + softly.assertThat(result.get(0).getScheduledAt()).isEqualTo(T1); + softly.assertThat(result.get(1).getScheduledAt()).isEqualTo(T2); }); } @Test - @DisplayName("ํƒ€ ๋ฉค๋ฒ„์˜ NotificationHistory๋Š” ํฌํ•จ๋˜์ง€ ์•Š๋Š”๋‹ค") - void findAllByMember_AndStatus_excludesOtherMembers() { + @DisplayName("ํƒ€ ๋ฉค๋ฒ„์˜ ReviewCycle์€ ํฌํ•จ๋˜์ง€ ์•Š๋Š”๋‹ค") + void findAllByMemberAndStatus_excludesOtherMembers() { // given final Member member = saveMember("user@test.com"); final Member other = saveMember("other@test.com"); - saveNotificationHistory(member, T1, NotificationStatus.PENDING); - saveNotificationHistory(other, T1, NotificationStatus.PENDING); + saveReviewCycle(member, T1, NotificationStatus.PENDING); + saveReviewCycle(other, T1, NotificationStatus.PENDING); // when - final List result = notificationHistoryRepository + final List result = reviewCycleRepository .findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING); // then assertThat(result).hasSize(1); - assertThat(result.getFirst().getReviewCycle().getScheduledAt()).isEqualTo(T1); + assertThat(result.getFirst().getScheduledAt()).isEqualTo(T1); } @Test @DisplayName("SENT/FAILED ์ƒํƒœ๋Š” ์กฐํšŒ์—์„œ ์ œ์™ธ๋œ๋‹ค") - void findAllPendingByMember_excludesNonAndStatus() { + void findAllByMemberAndStatus_excludesNonPending() { // given final Member member = saveMember("user@test.com"); - saveNotificationHistory(member, T1, NotificationStatus.SENT); - saveNotificationHistory(member, T2, NotificationStatus.FAILED); + saveReviewCycle(member, T1, NotificationStatus.SENT); + saveReviewCycle(member, T2, NotificationStatus.FAILED); // when - final List result = notificationHistoryRepository + final List result = reviewCycleRepository .findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING); // then @@ -91,13 +87,13 @@ void findAllPendingByMember_excludesNonAndStatus() { } @Test - @DisplayName("NotificationHistory๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void findAllByMember_AndStatus_empty() { + @DisplayName("ReviewCycle์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void findAllByMemberAndStatus_empty() { // given final Member member = saveMember("user@test.com"); // when - final List result = notificationHistoryRepository + final List result = reviewCycleRepository .findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING); // then @@ -108,11 +104,13 @@ private Member saveMember(final String email) { return memberRepository.save(Member.withoutId(Email.from(email))); } - private NotificationHistory saveNotificationHistory(final Member member, final LocalDateTime scheduledAt, - final NotificationStatus status) { + private ReviewCycle saveReviewCycle( + final Member member, + final LocalDateTime scheduledAt, + final NotificationStatus status + ) { final Review review = reviewRepository.save(Review.withoutId(member, ReviewURL.from("https://example.com"))); - final ReviewCycle cycle = reviewCycleRepository.save(ReviewCycle.withoutId(review, scheduledAt)); - return notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, status, scheduledAt.plusHours(24))); + return reviewCycleRepository.save( + ReviewCycle.withoutId(review, scheduledAt, status, scheduledAt.plusHours(24))); } } diff --git a/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryTest.java b/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryTest.java index 740cb56..ee39211 100644 --- a/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryTest.java +++ b/src/test/java/com/recyclestudy/review/repository/ReviewCycleRepositoryTest.java @@ -3,7 +3,6 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -import com.recyclestudy.review.domain.NotificationHistory; import com.recyclestudy.review.domain.NotificationStatus; import com.recyclestudy.review.domain.Review; import com.recyclestudy.review.domain.ReviewCycle; @@ -23,9 +22,6 @@ class ReviewCycleRepositoryTest { @Autowired private ReviewCycleRepository reviewCycleRepository; - @Autowired - private NotificationHistoryRepository notificationHistoryRepository; - @Autowired private MemberRepository memberRepository; @@ -41,10 +37,8 @@ class ReviewCycleRepositoryTest { @DisplayName("FAILED ์ƒํƒœ์ด๊ณ  deadline์ด ํ˜„์žฌ๋ณด๋‹ค ๋ฏธ๋ž˜์ด๋ฉด ์žฌ์‹œ๋„ ๋Œ€์ƒ์— ํฌํ•จ๋œ๋‹ค") void findAllRetryableCycles_success() { // given - final ReviewCycle cycle = saveCycle("retry@email.com", SCHEDULED_AT); - notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, NotificationStatus.PENDING, FUTURE_DEADLINE)); - notificationHistoryRepository.updateStatusAndIncrementFailCount( + final ReviewCycle cycle = saveCycle("retry@email.com", SCHEDULED_AT, FUTURE_DEADLINE); + reviewCycleRepository.updateStatusAndIncrementFailCount( List.of(cycle.getId()), NotificationStatus.FAILED, NOW); // when @@ -60,9 +54,7 @@ void findAllRetryableCycles_success() { @DisplayName("PENDING ์ƒํƒœ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ๋Š” ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค") void findAllRetryableCycles_pendingOnly() { // given - final ReviewCycle cycle = saveCycle("pending@email.com", SCHEDULED_AT); - notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, NotificationStatus.PENDING, FUTURE_DEADLINE)); + saveCycle("pending@email.com", SCHEDULED_AT, FUTURE_DEADLINE); // when final List results = reviewCycleRepository.findAllRetryableCycles( @@ -76,10 +68,8 @@ void findAllRetryableCycles_pendingOnly() { @DisplayName("์ด๋ฏธ SENT ์ƒํƒœ์ด๋ฉด ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค") void findAllRetryableCycles_alreadySent() { // given - final ReviewCycle cycle = saveCycle("sent@email.com", SCHEDULED_AT); - notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, NotificationStatus.PENDING, FUTURE_DEADLINE)); - notificationHistoryRepository.updateStatus( + final ReviewCycle cycle = saveCycle("sent@email.com", SCHEDULED_AT, FUTURE_DEADLINE); + reviewCycleRepository.updateStatus( List.of(cycle.getId()), NotificationStatus.SENT, NOW); // when @@ -94,10 +84,8 @@ void findAllRetryableCycles_alreadySent() { @DisplayName("deadline์ด ํ˜„์žฌ๋ณด๋‹ค ๊ณผ๊ฑฐ์ด๋ฉด ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค") void findAllRetryableCycles_deadlineExpired() { // given - final ReviewCycle cycle = saveCycle("expired@email.com", SCHEDULED_AT); - notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, NotificationStatus.PENDING, PAST_DEADLINE)); - notificationHistoryRepository.updateStatusAndIncrementFailCount( + final ReviewCycle cycle = saveCycle("expired@email.com", SCHEDULED_AT, PAST_DEADLINE); + reviewCycleRepository.updateStatusAndIncrementFailCount( List.of(cycle.getId()), NotificationStatus.FAILED, NOW); // when @@ -109,10 +97,10 @@ void findAllRetryableCycles_deadlineExpired() { } @Test - @DisplayName("notification_history๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค") - void findAllRetryableCycles_noHistory() { + @DisplayName("PENDING ์ƒํƒœ์ธ ๊ฒฝ์šฐ ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค") + void findAllRetryableCycles_pendingIsNotRetryable() { // given - saveCycle("new@email.com", SCHEDULED_AT); + saveCycle("new@email.com", SCHEDULED_AT, FUTURE_DEADLINE); // when final List results = reviewCycleRepository.findAllRetryableCycles( @@ -126,12 +114,9 @@ void findAllRetryableCycles_noHistory() { @DisplayName("failCount๊ฐ€ ์•„๋ฌด๋ฆฌ ๋†’์•„๋„ deadline์ด ๋ฏธ๋ž˜์ด๋ฉด ์žฌ์‹œ๋„ ๋Œ€์ƒ์— ํฌํ•จ๋œ๋‹ค") void findAllRetryableCycles_failCountDoesNotAffectEligibility() { // given - final ReviewCycle cycle = saveCycle("many-fails@email.com", SCHEDULED_AT); - notificationHistoryRepository.save( - NotificationHistory.withoutId(cycle, NotificationStatus.PENDING, FUTURE_DEADLINE)); - // failCount๋ฅผ 10์œผ๋กœ ์„ค์ •ํ•ด๋„ deadline์ด ๋ฏธ๋ž˜์ด๋ฉด ์žฌ์‹œ๋„ ๋Œ€์ƒ + final ReviewCycle cycle = saveCycle("many-fails@email.com", SCHEDULED_AT, FUTURE_DEADLINE); for (int i = 0; i < 10; i++) { - notificationHistoryRepository.updateStatusAndIncrementFailCount( + reviewCycleRepository.updateStatusAndIncrementFailCount( List.of(cycle.getId()), NotificationStatus.FAILED, NOW); } @@ -143,9 +128,14 @@ void findAllRetryableCycles_failCountDoesNotAffectEligibility() { assertThat(results).hasSize(1); } - private ReviewCycle saveCycle(final String email, final LocalDateTime scheduledAt) { + private ReviewCycle saveCycle( + final String email, + final LocalDateTime scheduledAt, + final LocalDateTime deadline + ) { final Member member = memberRepository.save(Member.withoutId(Email.from(email))); final Review review = reviewRepository.save(Review.withoutId(member, ReviewURL.from("url"))); - return reviewCycleRepository.save(ReviewCycle.withoutId(review, scheduledAt)); + return reviewCycleRepository.save( + ReviewCycle.withoutId(review, scheduledAt, NotificationStatus.PENDING, deadline)); } } diff --git a/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java b/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java deleted file mode 100644 index 80da147..0000000 --- a/src/test/java/com/recyclestudy/review/service/NotificationHistoryServiceTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.recyclestudy.review.service; - -import com.recyclestudy.review.domain.NotificationStatus; -import com.recyclestudy.review.repository.NotificationHistoryRepository; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -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.BDDMockito; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class NotificationHistoryServiceTest { - - @Mock - NotificationHistoryRepository notificationHistoryRepository; - - @Mock - Clock clock; - - @InjectMocks - NotificationHistoryService notificationHistoryService; - - @Test - @DisplayName("SENT ์ƒํƒœ๋กœ ์—…๋ฐ์ดํŠธํ•œ๋‹ค") - void updateStatus_sent() { - // given - final List reviewCycleIds = List.of(1L, 2L); - BDDMockito.given(clock.instant()) - .willReturn(Instant.parse("2026-01-01T00:00:00Z")); - BDDMockito.given(clock.getZone()) - .willReturn(ZoneId.of("UTC")); - - // when - notificationHistoryService.updateStatus(reviewCycleIds, NotificationStatus.SENT); - - // then - verify(notificationHistoryRepository).updateStatus( - eq(reviewCycleIds), eq(NotificationStatus.SENT), any(LocalDateTime.class)); - } - - @Test - @DisplayName("FAILED ์ƒํƒœ๋กœ ์—…๋ฐ์ดํŠธํ•˜๋ฉด ๋ฐฐ์น˜ ์ฟผ๋ฆฌ๋กœ failCount๋ฅผ 1 ์ฆ๊ฐ€์‹œํ‚จ๋‹ค") - void updateStatus_failed() { - // given - final List reviewCycleIds = List.of(1L, 2L); - BDDMockito.given(clock.instant()).willReturn(Instant.parse("2026-01-01T00:00:00Z")); - BDDMockito.given(clock.getZone()).willReturn(ZoneId.of("UTC")); - - // when - notificationHistoryService.updateStatus(reviewCycleIds, NotificationStatus.FAILED); - - // then - verify(notificationHistoryRepository).updateStatusAndIncrementFailCount( - eq(reviewCycleIds), eq(NotificationStatus.FAILED), any(LocalDateTime.class)); - } -} diff --git a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java index 57049b0..961046d 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewCycleServiceTest.java @@ -5,18 +5,17 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -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.service.input.NextReviewInput; import com.recyclestudy.review.service.input.ReviewSendInput; import com.recyclestudy.review.service.output.NextReviewOutput; 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.LocalDateTime; import java.time.ZoneOffset; @@ -27,13 +26,17 @@ import org.junit.jupiter.api.extension.ExtendWith; 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.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) class ReviewCycleServiceTest { @@ -44,8 +47,8 @@ class ReviewCycleServiceTest { @Mock private MemberRepository memberRepository; - @Mock - private NotificationHistoryRepository notificationHistoryRepository; + @Spy + Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); @InjectMocks private ReviewCycleService reviewCycleService; @@ -59,7 +62,8 @@ 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); + final ReviewCycle reviewCycle = ReviewCycle.withoutId(review, scheduledAt, NotificationStatus.PENDING, + scheduledAt.plusHours(24)); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt, NotificationStatus.PENDING)).willReturn( List.of(reviewCycle)); @@ -102,8 +106,10 @@ 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); - final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING, + scheduledAt.plusHours(24)); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING, + scheduledAt.plusHours(24)); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt, NotificationStatus.PENDING)).willReturn( List.of(cycle1, cycle2)); @@ -132,8 +138,10 @@ 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); - final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt); + final ReviewCycle cycle1 = ReviewCycle.withoutId(review1, scheduledAt, NotificationStatus.PENDING, + scheduledAt.plusHours(24)); + final ReviewCycle cycle2 = ReviewCycle.withoutId(review2, scheduledAt, NotificationStatus.PENDING, + scheduledAt.plusHours(24)); given(reviewCycleRepository.findAllByScheduledAt(scheduledAt, NotificationStatus.PENDING)).willReturn( List.of(cycle1, cycle2)); @@ -164,7 +172,7 @@ void findNextReview_empty() { final Member member = Member.withoutId(Email.from("user@test.com")); given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); - given(notificationHistoryRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) + given(reviewCycleRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) .willReturn(List.of()); // when @@ -188,14 +196,12 @@ void findNextReview_returnsEarliestGroup() { final LocalDateTime t2 = LocalDateTime.of(2026, 3, 7, 9, 0); final Review review = Review.withoutId(member, ReviewURL.from("https://example.com")); - final NotificationHistory nh1 = NotificationHistory.withoutId(ReviewCycle.withoutId(review, t1), - NotificationStatus.PENDING, t2); - final NotificationHistory nh2 = NotificationHistory.withoutId(ReviewCycle.withoutId(review, t2), - NotificationStatus.PENDING, t2.plusHours(24)); + final ReviewCycle rc1 = ReviewCycle.withoutId(review, t1, NotificationStatus.PENDING, t2); + final ReviewCycle rc2 = ReviewCycle.withoutId(review, t2, NotificationStatus.PENDING, t2.plusHours(24)); given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); - given(notificationHistoryRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) - .willReturn(List.of(nh1, nh2)); + given(reviewCycleRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) + .willReturn(List.of(rc1, rc2)); // when final NextReviewOutput result = reviewCycleService.findNextReview(input); @@ -208,7 +214,7 @@ void findNextReview_returnsEarliestGroup() { } @Test - @DisplayName("๊ฐ™์€ scheduledAt์˜ ์—ฌ๋Ÿฌ NotificationHistory count๋ฅผ ์ •ํ™•ํžˆ ์ง‘๊ณ„ํ•œ๋‹ค") + @DisplayName("๊ฐ™์€ scheduledAt์˜ ์—ฌ๋Ÿฌ ReviewCycle count๋ฅผ ์ •ํ™•ํžˆ ์ง‘๊ณ„ํ•œ๋‹ค") void findNextReview_countsSameScheduledAt() { // given final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); @@ -217,16 +223,13 @@ void findNextReview_countsSameScheduledAt() { final LocalDateTime t1 = LocalDateTime.of(2026, 3, 6, 9, 0); final Review review = Review.withoutId(member, ReviewURL.from("https://example.com")); - final NotificationHistory nh1 = NotificationHistory.withoutId(ReviewCycle.withoutId(review, t1), - NotificationStatus.PENDING, t1.plusHours(24)); - final NotificationHistory nh2 = NotificationHistory.withoutId(ReviewCycle.withoutId(review, t1), - NotificationStatus.PENDING, t1.plusHours(24)); - final NotificationHistory nh3 = NotificationHistory.withoutId(ReviewCycle.withoutId(review, t1), - NotificationStatus.PENDING, t1.plusHours(24)); + final ReviewCycle rc1 = ReviewCycle.withoutId(review, t1, NotificationStatus.PENDING, t1.plusHours(24)); + final ReviewCycle rc2 = ReviewCycle.withoutId(review, t1, NotificationStatus.PENDING, t1.plusHours(24)); + final ReviewCycle rc3 = ReviewCycle.withoutId(review, t1, NotificationStatus.PENDING, t1.plusHours(24)); given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); - given(notificationHistoryRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) - .willReturn(List.of(nh1, nh2, nh3)); + given(reviewCycleRepository.findAllByMemberAndStatus(member.getId(), NotificationStatus.PENDING)) + .willReturn(List.of(rc1, rc2, rc3)); // when final NextReviewOutput result = reviewCycleService.findNextReview(input); @@ -252,4 +255,61 @@ void findNextReview_unauthorized() { assertThatThrownBy(() -> reviewCycleService.findNextReview(input)) .isInstanceOf(UnauthorizedException.class); } + + @Test + @DisplayName("๋นˆ ๋ฆฌ์ŠคํŠธ ์ „๋‹ฌ ์‹œ ์•„๋ฌด๊ฒƒ๋„ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค") + void updateStatus_emptyList_doesNothing() { + // when + reviewCycleService.updateStatus(List.of(), NotificationStatus.SENT); + + // then + verifyNoInteractions(reviewCycleRepository); + } + + @Test + @DisplayName("SENT ์ƒํƒœ๋กœ ์—…๋ฐ์ดํŠธํ•œ๋‹ค") + void updateStatus_sent() { + // given + final List ids = List.of(1L, 2L); + given(reviewCycleRepository.updateStatus(eq(ids), eq(NotificationStatus.SENT), any(LocalDateTime.class))) + .willReturn(2); + + // when + reviewCycleService.updateStatus(ids, NotificationStatus.SENT); + + // then + verify(reviewCycleRepository).updateStatus(eq(ids), eq(NotificationStatus.SENT), any(LocalDateTime.class)); + } + + @Test + @DisplayName("FAILED ์ƒํƒœ๋กœ ์—…๋ฐ์ดํŠธํ•˜๋ฉด failCount ์ฆ๊ฐ€ ์ฟผ๋ฆฌ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค") + void updateStatus_failed() { + // given + final List ids = List.of(1L, 2L); + given(reviewCycleRepository.updateStatusAndIncrementFailCount( + eq(ids), eq(NotificationStatus.FAILED), any(LocalDateTime.class))) + .willReturn(2); + + // when + reviewCycleService.updateStatus(ids, NotificationStatus.FAILED); + + // then + verify(reviewCycleRepository).updateStatusAndIncrementFailCount( + eq(ids), eq(NotificationStatus.FAILED), any(LocalDateTime.class)); + } + + @Test + @DisplayName("์—…๋ฐ์ดํŠธ ๊ฑด์ˆ˜๊ฐ€ ์š”์ฒญ ๊ฑด์ˆ˜์™€ ๋‹ค๋ฅด๋ฉด warn ๋กœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๊ณ  ์ •์ƒ ์ข…๋ฃŒ๋œ๋‹ค") + void updateStatus_mismatch_logsWarn() { + // given + final List ids = List.of(1L, 2L); + given(reviewCycleRepository.updateStatus(eq(ids), eq(NotificationStatus.SENT), any(LocalDateTime.class))) + .willReturn(1); + + // when (์˜ˆ์™ธ ์—†์ด ์ •์ƒ ์ข…๋ฃŒ๋˜์–ด์•ผ ํ•จ) + reviewCycleService.updateStatus(ids, NotificationStatus.SENT); + + // then + verify(reviewCycleRepository).updateStatus(eq(ids), eq(NotificationStatus.SENT), any(LocalDateTime.class)); + } } diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index a9d18bd..f0854a5 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -11,12 +11,10 @@ import com.recyclestudy.member.domain.Email; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; -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; @@ -66,9 +64,6 @@ class ReviewServiceTest { @Mock CycleSelectionResolverRegistry cycleSelectionResolverRegistry; - @Mock - NotificationHistoryRepository notificationHistoryRepository; - @Spy Clock clock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneId.of("UTC")); @@ -94,28 +89,26 @@ 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)); final List durations = List.of(Duration.ofDays(1)); given(memberRepository.findByIdentifier(any(DeviceIdentifier.class))).willReturn(Optional.of(member)); given(cycleSelectionResolverRegistry.resolve(cycleSelection)).willReturn(durations); given(reviewRepository.save(any(Review.class))).willReturn(review); - given(reviewCycleRepository.saveAll(anyList())).willReturn(List.of(cycle)); + + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + given(reviewCycleRepository.saveAll(captor.capture())).willAnswer(invocation -> invocation.getArgument(0)); // when final ReviewSaveOutput actual = reviewService.saveReview(input); // then - final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(notificationHistoryRepository).saveAll(captor.capture()); - final LocalDateTime expectedDeadline = now.plusDays(1).plusHours(24); 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); - softAssertions.assertThat(captor.getValue()).allMatch(h -> h.getDeadline().equals(expectedDeadline)); + softAssertions.assertThat(captor.getValue()).allMatch(rc -> rc.getStatus() == NotificationStatus.PENDING); + softAssertions.assertThat(captor.getValue()).allMatch(rc -> rc.getDeadline().equals(expectedDeadline)); }); verify(memberRepository).findByIdentifier(any(DeviceIdentifier.class)); @@ -193,7 +186,6 @@ void saveReview_withCustomCycle_owner() { final Member member = Member.withoutId(Email.from("test@test.com")); final Review review = Review.withoutId(member, input.url()); - final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1)); final CycleOption cycleOption = mock(CycleOption.class); final List durations = List.of(Duration.ofDays(1)); @@ -202,7 +194,7 @@ void saveReview_withCustomCycle_owner() { given(cycleOptionRepository.findById(cycleOptionId)).willReturn(Optional.of(cycleOption)); given(cycleOption.isOwner(member)).willReturn(true); given(cycleSelectionResolverRegistry.resolve(cycleSelection)).willReturn(durations); - given(reviewCycleRepository.saveAll(anyList())).willReturn(List.of(cycle)); + given(reviewCycleRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); // when reviewService.saveReview(input);