1010import com .recyclestudy .review .domain .ReviewURL ;
1111import java .time .LocalDateTime ;
1212import java .util .List ;
13+ import java .util .Optional ;
1314import org .junit .jupiter .api .DisplayName ;
1415import org .junit .jupiter .api .Test ;
1516import org .springframework .beans .factory .annotation .Autowired ;
@@ -32,23 +33,23 @@ class ReviewCycleRepositoryTest {
3233 @ Autowired
3334 private ReviewRepository reviewRepository ;
3435
35- private static final LocalDateTime CUTOFF = LocalDateTime .now ().minusDays (1 );
36- private static final LocalDateTime LONG_AGO = LocalDateTime .now ().minusDays (2 );
37- private static final LocalDateTime RECENT = LocalDateTime .now ().minusHours (1 );
36+ private static final LocalDateTime NOW = LocalDateTime .of (2026 , 1 , 1 , 0 , 0 , 0 );
37+ private static final LocalDateTime FUTURE_DEADLINE = NOW .plusHours (23 );
38+ private static final LocalDateTime PAST_DEADLINE = NOW .minusHours (1 );
39+ private static final LocalDateTime SCHEDULED_AT = NOW .minusDays (1 );
3840
3941 @ Test
40- @ DisplayName ("FAILED 상태이고 failCount가 최대 횟수 미만이면 재시도 대상에 포함된다" )
42+ @ DisplayName ("FAILED 상태이고 deadline이 현재보다 미래이면 재시도 대상에 포함된다" )
4143 void findAllRetryableCycles_success () {
4244 // given
43- final ReviewCycle cycle = saveCycle ("retry@email.com" , LONG_AGO );
45+ final ReviewCycle cycle = saveCycle ("retry@email.com" , SCHEDULED_AT );
4446 notificationHistoryRepository .save (NotificationHistory .withoutId (cycle , NotificationStatus .PENDING ));
45-
4647 notificationHistoryRepository .updateStatusWithIncrementFailCount (
47- List . of ( cycle .getId ()) , NotificationStatus .FAILED , LocalDateTime . now () );
48+ cycle .getId (), NotificationStatus .FAILED , NOW , FUTURE_DEADLINE );
4849
4950 // when
5051 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
51- NotificationStatus .FAILED , 3 , CUTOFF );
52+ NotificationStatus .FAILED , NOW );
5253
5354 // then
5455 assertThat (results ).hasSize (1 );
@@ -59,12 +60,12 @@ void findAllRetryableCycles_success() {
5960 @ DisplayName ("PENDING 상태만 있는 경우는 재시도 대상이 아니다" )
6061 void findAllRetryableCycles_pendingOnly () {
6162 // given
62- final ReviewCycle cycle = saveCycle ("pending@email.com" , LONG_AGO );
63+ final ReviewCycle cycle = saveCycle ("pending@email.com" , SCHEDULED_AT );
6364 notificationHistoryRepository .save (NotificationHistory .withoutId (cycle , NotificationStatus .PENDING ));
6465
6566 // when
6667 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
67- NotificationStatus .FAILED , 3 , CUTOFF );
68+ NotificationStatus .FAILED , NOW );
6869
6970 // then
7071 assertThat (results ).isEmpty ();
@@ -74,36 +75,31 @@ void findAllRetryableCycles_pendingOnly() {
7475 @ DisplayName ("이미 SENT 상태이면 재시도 대상이 아니다" )
7576 void findAllRetryableCycles_alreadySent () {
7677 // given
77- final ReviewCycle cycle = saveCycle ("sent@email.com" , LONG_AGO );
78+ final ReviewCycle cycle = saveCycle ("sent@email.com" , SCHEDULED_AT );
7879 notificationHistoryRepository .save (NotificationHistory .withoutId (cycle , NotificationStatus .PENDING ));
79-
8080 notificationHistoryRepository .updateStatus (
81- List .of (cycle .getId ()), NotificationStatus .SENT , LocalDateTime . now () );
81+ List .of (cycle .getId ()), NotificationStatus .SENT , NOW );
8282
8383 // when
8484 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
85- NotificationStatus .FAILED , 3 , CUTOFF );
85+ NotificationStatus .FAILED , NOW );
8686
8787 // then
8888 assertThat (results ).isEmpty ();
8989 }
9090
9191 @ Test
92- @ DisplayName ("failCount가 최대 재시도 횟수에 도달하면 재시도 대상이 아니다" )
93- void findAllRetryableCycles_maxRetryReached () {
92+ @ DisplayName ("deadline이 현재보다 과거이면 재시도 대상이 아니다" )
93+ void findAllRetryableCycles_deadlineExpired () {
9494 // given
95- final ReviewCycle cycle = saveCycle ("max @email.com" , LONG_AGO );
95+ final ReviewCycle cycle = saveCycle ("expired @email.com" , SCHEDULED_AT );
9696 notificationHistoryRepository .save (NotificationHistory .withoutId (cycle , NotificationStatus .PENDING ));
97-
98- // failCount를 3으로 만들기 위해 3번 increment
99- for (int i = 0 ; i < 3 ; i ++) {
100- notificationHistoryRepository .updateStatusWithIncrementFailCount (
101- List .of (cycle .getId ()), NotificationStatus .FAILED , LocalDateTime .now ());
102- }
97+ notificationHistoryRepository .updateStatusWithIncrementFailCount (
98+ cycle .getId (), NotificationStatus .FAILED , NOW , PAST_DEADLINE );
10399
104100 // when
105101 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
106- NotificationStatus .FAILED , 3 , CUTOFF );
102+ NotificationStatus .FAILED , NOW );
107103
108104 // then
109105 assertThat (results ).isEmpty ();
@@ -113,32 +109,71 @@ void findAllRetryableCycles_maxRetryReached() {
113109 @ DisplayName ("notification_history가 없는 경우 재시도 대상이 아니다" )
114110 void findAllRetryableCycles_noHistory () {
115111 // given
116- saveCycle ("new@email.com" , LONG_AGO );
112+ saveCycle ("new@email.com" , SCHEDULED_AT );
117113
118114 // when
119115 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
120- NotificationStatus .FAILED , 3 , CUTOFF );
116+ NotificationStatus .FAILED , NOW );
121117
122118 // then
123119 assertThat (results ).isEmpty ();
124120 }
125121
126122 @ Test
127- @ DisplayName ("scheduledAt이 cutoffDateTime보다 최근인 단기 주기는 재시도 대상이 아니다 " )
128- void findAllRetryableCycles_shortCycle () {
123+ @ DisplayName ("failCount가 아무리 높아도 deadline이 미래이면 재시도 대상에 포함된다 " )
124+ void findAllRetryableCycles_failCountDoesNotAffectEligibility () {
129125 // given
130- final ReviewCycle cycle = saveCycle ("short @email.com" , RECENT );
126+ final ReviewCycle cycle = saveCycle ("many-fails @email.com" , SCHEDULED_AT );
131127 notificationHistoryRepository .save (NotificationHistory .withoutId (cycle , NotificationStatus .PENDING ));
132-
133- notificationHistoryRepository .updateStatusWithIncrementFailCount (
134- List .of (cycle .getId ()), NotificationStatus .FAILED , LocalDateTime .now ());
128+ // failCount를 10으로 설정해도 deadline이 미래이면 재시도 대상
129+ for (int i = 0 ; i < 10 ; i ++) {
130+ notificationHistoryRepository .updateStatusWithIncrementFailCount (
131+ cycle .getId (), NotificationStatus .FAILED , NOW , FUTURE_DEADLINE );
132+ }
135133
136134 // when
137135 final List <ReviewCycle > results = reviewCycleRepository .findAllRetryableCycles (
138- NotificationStatus .FAILED , 3 , CUTOFF );
136+ NotificationStatus .FAILED , NOW );
139137
140138 // then
141- assertThat (results ).isEmpty ();
139+ assertThat (results ).hasSize (1 );
140+ }
141+
142+ @ Test
143+ @ DisplayName ("다음 주기가 있으면 scheduledAt이 가장 가까운 ReviewCycle을 반환한다" )
144+ void findNextScheduledAt_success () {
145+ // given
146+ final Member member = memberRepository .save (Member .withoutId (Email .from ("next@email.com" )));
147+ final Review review = reviewRepository .save (Review .withoutId (member , ReviewURL .from ("url" )));
148+ final ReviewCycle current = reviewCycleRepository .save (ReviewCycle .withoutId (review , NOW .plusHours (1 )));
149+ final ReviewCycle next = reviewCycleRepository .save (ReviewCycle .withoutId (review , NOW .plusDays (1 )));
150+ reviewCycleRepository .save (ReviewCycle .withoutId (review , NOW .plusDays (7 )));
151+
152+ // when
153+ final Optional <ReviewCycle > result = reviewCycleRepository
154+ .findFirstByReview_IdAndScheduledAtGreaterThanOrderByScheduledAtAsc (
155+ review .getId (), current .getScheduledAt ());
156+
157+ // then
158+ assertThat (result ).isPresent ();
159+ assertThat (result .get ().getId ()).isEqualTo (next .getId ());
160+ }
161+
162+ @ Test
163+ @ DisplayName ("마지막 주기이면 Optional.empty()를 반환한다" )
164+ void findNextScheduledAt_lastCycle () {
165+ // given
166+ final Member member = memberRepository .save (Member .withoutId (Email .from ("last@email.com" )));
167+ final Review review = reviewRepository .save (Review .withoutId (member , ReviewURL .from ("url" )));
168+ final ReviewCycle last = reviewCycleRepository .save (ReviewCycle .withoutId (review , NOW .plusDays (30 )));
169+
170+ // when
171+ final Optional <ReviewCycle > result = reviewCycleRepository
172+ .findFirstByReview_IdAndScheduledAtGreaterThanOrderByScheduledAtAsc (
173+ review .getId (), last .getScheduledAt ());
174+
175+ // then
176+ assertThat (result ).isEmpty ();
142177 }
143178
144179 private ReviewCycle saveCycle (final String email , final LocalDateTime scheduledAt ) {
0 commit comments