44import static org .mockito .ArgumentMatchers .any ;
55import static org .mockito .Mockito .doThrow ;
66
7+ import io .ejangs .docsa .global .mongo .deletion .app .MongoDeleteOutboxCompleteService ;
78import io .ejangs .docsa .global .mongo .deletion .app .MongoDeleteOutboxWorker ;
89import io .ejangs .docsa .global .mongo .deletion .app .MongoDeleteService ;
910import io .ejangs .docsa .global .mongo .deletion .dao .mysql .MongoDeleteOutboxRepository ;
1415import io .ejangs .docsa .global .mongo .deletion .entity .MongoDeleteOutbox .OutboxStatus ;
1516import io .ejangs .docsa .global .mongo .deletion .entity .MongoDeleteOutbox .TriggerType ;
1617import io .ejangs .docsa .global .mongo .deletion .entity .MongoDeleteOutboxFactory ;
18+ import java .time .LocalDateTime ;
1719import java .util .List ;
1820import java .util .UUID ;
1921import org .junit .jupiter .api .DisplayName ;
22+ import org .junit .jupiter .api .BeforeEach ;
2023import org .junit .jupiter .api .Test ;
2124import org .springframework .beans .factory .annotation .Autowired ;
2225import org .springframework .boot .test .context .SpringBootTest ;
26+ import org .springframework .jdbc .core .JdbcTemplate ;
2327import org .springframework .test .context .ActiveProfiles ;
2428import org .springframework .test .context .bean .override .mockito .MockitoSpyBean ;
29+ import org .springframework .test .util .ReflectionTestUtils ;
2530
2631@ SpringBootTest
2732@ ActiveProfiles ("test" )
@@ -36,9 +41,20 @@ class MongoDeleteOutboxTest {
3641 @ Autowired
3742 private MongoDeleteOutboxWorker mongoDeleteOutboxWorker ;
3843
44+ @ Autowired
45+ private MongoDeleteOutboxCompleteService mongoDeleteOutboxCompleteService ;
46+
47+ @ Autowired
48+ private JdbcTemplate jdbcTemplate ;
49+
3950 @ MockitoSpyBean
4051 private MongoDeleteService mongoDeleteService ;
4152
53+ @ BeforeEach
54+ void cleanOutbox () {
55+ mongoDeleteOutboxRepository .deleteAll ();
56+ }
57+
4258 @ Test
4359 @ DisplayName ("Outbox 워커가 OPEN 건을 처리하면 DONE으로 완료된다" )
4460 void workerDone () {
@@ -84,6 +100,106 @@ void workerFailedAfterMaxRetry() {
84100 assertThat (failed .getLastError ()).contains ("always fail" );
85101 }
86102
103+ @ Test
104+ @ DisplayName ("동일 키 create 중복 호출 시 outbox는 1건만 유지된다" )
105+ void factoryDedupeWithSameKey () {
106+ String originId = "dup-" + UUID .randomUUID ();
107+ MongoDeleteOutbox first = mongoDeleteOutboxFactory .create (
108+ TriggerType .COMPENSATE ,
109+ DomainType .DOC ,
110+ OriginType .DOC_ID ,
111+ originId ,
112+ new MongoIdsDto (List .of ("save-" + originId ), List .of (), List .of ())
113+ );
114+ MongoDeleteOutbox second = mongoDeleteOutboxFactory .create (
115+ TriggerType .COMPENSATE ,
116+ DomainType .DOC ,
117+ OriginType .DOC_ID ,
118+ originId ,
119+ new MongoIdsDto (List .of ("save-" + originId ), List .of (), List .of ())
120+ );
121+
122+ assertThat (first .getId ()).isEqualTo (second .getId ());
123+ assertThat (mongoDeleteOutboxRepository .findAll ()).hasSize (1 );
124+ }
125+
126+ @ Test
127+ @ DisplayName ("PROCESSING timeout 건은 run() 시작 시 복구되어 재처리된다" )
128+ void workerRecoversTimedOutProcessingBeforeDelete () {
129+ MongoDeleteOutbox outbox = createOpenOutbox ();
130+ mongoDeleteOutboxCompleteService .claimOpen (outbox .getId ());
131+
132+ MongoDeleteOutbox processing = mongoDeleteOutboxRepository .findById (outbox .getId ()).orElseThrow ();
133+ ReflectionTestUtils .setField (processing , "updatedAt" , LocalDateTime .now ().minusMinutes (10 ));
134+ mongoDeleteOutboxRepository .saveAndFlush (processing );
135+ jdbcTemplate .update (
136+ "update mongo_delete_outbox set updated_at = ? where id = ?" ,
137+ LocalDateTime .now ().minusMinutes (10 ),
138+ outbox .getId ()
139+ );
140+
141+ mongoDeleteOutboxWorker .run ();
142+
143+ MongoDeleteOutbox done = mongoDeleteOutboxRepository .findById (outbox .getId ()).orElseThrow ();
144+ assertThat (done .getStatus ()).isEqualTo (OutboxStatus .DONE );
145+ assertThat (done .getRetryCount ()).isEqualTo (1 );
146+ }
147+
148+ @ Test
149+ @ DisplayName ("상태 불일치 호출(claim/done/retry)은 no-op으로 안전하게 무시된다" )
150+ void completeServiceNoOpOnStatusMismatch () {
151+ MongoDeleteOutbox outbox = createOpenOutbox ();
152+
153+ mongoDeleteOutboxCompleteService .done (outbox .getId ());
154+ mongoDeleteOutboxCompleteService .retry (outbox .getId (), "ignored" );
155+
156+ MongoDeleteOutbox open = mongoDeleteOutboxRepository .findById (outbox .getId ()).orElseThrow ();
157+ assertThat (open .getStatus ()).isEqualTo (OutboxStatus .OPEN );
158+ assertThat (open .getRetryCount ()).isEqualTo (0 );
159+ assertThat (open .getLastError ()).isNull ();
160+
161+ assertThat (mongoDeleteOutboxCompleteService .claimOpen (outbox .getId ())).isNotNull ();
162+ mongoDeleteOutboxCompleteService .done (outbox .getId ());
163+
164+ assertThat (mongoDeleteOutboxCompleteService .claimOpen (outbox .getId ())).isNull ();
165+ mongoDeleteOutboxCompleteService .retry (outbox .getId (), "ignored2" );
166+
167+ MongoDeleteOutbox done = mongoDeleteOutboxRepository .findById (outbox .getId ()).orElseThrow ();
168+ assertThat (done .getStatus ()).isEqualTo (OutboxStatus .DONE );
169+ assertThat (done .getRetryCount ()).isEqualTo (0 );
170+ }
171+
172+ @ Test
173+ @ DisplayName ("워커는 1회 실행 시 OPEN 최대 100건만 처리한다" )
174+ void workerProcessesAtMost100OpenRowsPerRun () {
175+ for (int i = 0 ; i < 101 ; i ++) {
176+ String originId = "batch-" + i + "-" + UUID .randomUUID ();
177+ mongoDeleteOutboxFactory .create (
178+ TriggerType .COMPENSATE ,
179+ DomainType .DOC ,
180+ OriginType .DOC_ID ,
181+ originId ,
182+ new MongoIdsDto (List .of ("save-" + originId ), List .of (), List .of ())
183+ );
184+ }
185+
186+ mongoDeleteOutboxWorker .run ();
187+
188+ List <MongoDeleteOutbox > firstRun = mongoDeleteOutboxRepository .findAll ();
189+ long doneCountAfterFirst = firstRun .stream ().filter (o -> o .getStatus () == OutboxStatus .DONE ).count ();
190+ long openCountAfterFirst = firstRun .stream ().filter (o -> o .getStatus () == OutboxStatus .OPEN ).count ();
191+ assertThat (doneCountAfterFirst ).isEqualTo (100 );
192+ assertThat (openCountAfterFirst ).isEqualTo (1 );
193+
194+ mongoDeleteOutboxWorker .run ();
195+
196+ List <MongoDeleteOutbox > secondRun = mongoDeleteOutboxRepository .findAll ();
197+ long doneCountAfterSecond = secondRun .stream ().filter (o -> o .getStatus () == OutboxStatus .DONE ).count ();
198+ long openCountAfterSecond = secondRun .stream ().filter (o -> o .getStatus () == OutboxStatus .OPEN ).count ();
199+ assertThat (doneCountAfterSecond ).isEqualTo (101 );
200+ assertThat (openCountAfterSecond ).isEqualTo (0 );
201+ }
202+
87203 private MongoDeleteOutbox createOpenOutbox () {
88204 String originId = UUID .randomUUID ().toString ();
89205 return mongoDeleteOutboxFactory .create (
0 commit comments