2222import java .util .UUID ;
2323
2424import static org .assertj .core .api .Assertions .assertThat ;
25+ import static org .assertj .core .api .Assertions .assertThatThrownBy ;
2526import static org .mockito .ArgumentMatchers .any ;
2627import static org .mockito .Mockito .never ;
28+ import static org .mockito .Mockito .times ;
2729import static org .mockito .Mockito .verify ;
2830import static org .mockito .Mockito .when ;
2931import org .springframework .dao .DataIntegrityViolationException ;
@@ -97,8 +99,8 @@ void returnsExistingEntryWhenIdempotencyKeyWasAlreadyRecorded() {
9799 }
98100
99101 @ Test
100- @ DisplayName ("동시 저장 충돌이 발생하면 기존 원장을 재조회해 반환한다" )
101- void returnsExistingEntryAfterConcurrentInsertConflict () {
102+ @ DisplayName ("create 과정에서 유니크 충돌이 발생하면 기존 원장을 재조회해 반환한다" )
103+ void returnsExistingEntryWhenCreateFailsWithDataIntegrityViolation () {
102104 LedgerEntry existingEntry = savedEntry ();
103105 when (ledgerEntryRepository .findByIdempotencyKey (IDEMPOTENCY_KEY ))
104106 .thenReturn (Optional .empty (), Optional .of (existingEntry ));
@@ -109,6 +111,88 @@ void returnsExistingEntryAfterConcurrentInsertConflict() {
109111
110112 assertThat (result .entryId ()).isEqualTo (existingEntry .getEntryId ());
111113 verify (ledgerEntryRepository ).save (any (LedgerEntry .class ));
114+ verify (ledgerEntryRepository , times (2 )).findByIdempotencyKey (IDEMPOTENCY_KEY );
115+ }
116+
117+ @ Test
118+ @ DisplayName ("create 과정의 유니크 충돌 후 기존 원장을 찾지 못하면 예외를 재전파한다" )
119+ void rethrowsDataIntegrityViolationWhenExistingEntryCannotBeFoundAfterCreateConflict () {
120+ DataIntegrityViolationException exception =
121+ new DataIntegrityViolationException ("duplicate idempotency key" );
122+ when (ledgerEntryRepository .findByIdempotencyKey (IDEMPOTENCY_KEY ))
123+ .thenReturn (Optional .empty (), Optional .empty ());
124+ when (ledgerEntryRepository .save (any (LedgerEntry .class )))
125+ .thenThrow (exception );
126+
127+ assertThatThrownBy (() -> ledgerEntryService .recordEntry (command ()))
128+ .isSameAs (exception );
129+
130+ verify (ledgerEntryRepository , times (2 )).findByIdempotencyKey (IDEMPOTENCY_KEY );
131+ }
132+
133+ @ Test
134+ @ DisplayName ("보정 원장을 기록하고 기존 원장을 역분개 상태로 저장한다" )
135+ void recordsAdjustmentAndReversesOriginalEntry () {
136+ LedgerEntry originalEntry = savedEntry ();
137+ LedgerEntry adjustmentEntry = adjustmentEntry ();
138+ RecordLedgerEntryCommand adjustmentCommand = adjustmentCommand ();
139+ when (ledgerEntryRepository .findByEntryId (originalEntry .getEntryId ()))
140+ .thenReturn (Optional .of (originalEntry ));
141+ when (ledgerEntryRepository .findByIdempotencyKey (adjustmentCommand .idempotencyKey ()))
142+ .thenReturn (Optional .empty ());
143+ when (ledgerEntryRepository .save (any (LedgerEntry .class )))
144+ .thenReturn (adjustmentEntry , originalEntry );
145+
146+ RecordLedgerEntryResult result = ledgerEntryService .recordAdjustmentEntry (
147+ originalEntry .getEntryId (), adjustmentCommand
148+ );
149+
150+ assertThat (result .entryId ()).isEqualTo (adjustmentEntry .getEntryId ());
151+ assertThat (originalEntry .getStatus ()).isEqualTo (LedgerStatus .REVERSED );
152+ assertThat (originalEntry .getReversedEntryId ()).isEqualTo (adjustmentEntry .getEntryId ());
153+ ArgumentCaptor <LedgerEntry > captor = ArgumentCaptor .forClass (LedgerEntry .class );
154+ verify (ledgerEntryRepository , times (2 )).save (captor .capture ());
155+ assertThat (captor .getAllValues ())
156+ .extracting (LedgerEntry ::getStatus )
157+ .containsExactly (LedgerStatus .POSTED , LedgerStatus .REVERSED );
158+ }
159+
160+ @ Test
161+ @ DisplayName ("원본 원장이 없으면 보정 원장을 기록하지 않는다" )
162+ void doesNotRecordAdjustmentWhenOriginalEntryDoesNotExist () {
163+ RecordLedgerEntryCommand adjustmentCommand = adjustmentCommand ();
164+ when (ledgerEntryRepository .findByEntryId ("missing-entry" ))
165+ .thenReturn (Optional .empty ());
166+
167+ org .assertj .core .api .Assertions .assertThatThrownBy (() ->
168+ ledgerEntryService .recordAdjustmentEntry ("missing-entry" , adjustmentCommand ))
169+ .isInstanceOf (java .util .NoSuchElementException .class );
170+
171+ verify (ledgerEntryRepository , never ()).findByIdempotencyKey (adjustmentCommand .idempotencyKey ());
172+ verify (ledgerEntryRepository , never ()).save (any (LedgerEntry .class ));
173+ }
174+
175+ @ Test
176+ @ DisplayName ("동일한 보정 멱등성 키가 있으면 기존 보정 원장을 사용한다" )
177+ void reusesExistingAdjustmentForSameIdempotencyKey () {
178+ LedgerEntry originalEntry = savedEntry ();
179+ LedgerEntry existingAdjustment = adjustmentEntry ();
180+ RecordLedgerEntryCommand adjustmentCommand = adjustmentCommand ();
181+ when (ledgerEntryRepository .findByEntryId (originalEntry .getEntryId ()))
182+ .thenReturn (Optional .of (originalEntry ));
183+ when (ledgerEntryRepository .findByIdempotencyKey (adjustmentCommand .idempotencyKey ()))
184+ .thenReturn (Optional .of (existingAdjustment ));
185+ when (ledgerEntryRepository .save (originalEntry )).thenReturn (originalEntry );
186+
187+ RecordLedgerEntryResult result = ledgerEntryService .recordAdjustmentEntry (
188+ originalEntry .getEntryId (), adjustmentCommand
189+ );
190+
191+ assertThat (result .entryId ()).isEqualTo (existingAdjustment .getEntryId ());
192+ assertThat (originalEntry .getStatus ()).isEqualTo (LedgerStatus .REVERSED );
193+ assertThat (originalEntry .getReversedEntryId ()).isEqualTo (existingAdjustment .getEntryId ());
194+ verify (ledgerEntryRepository , never ()).save (existingAdjustment );
195+ verify (ledgerEntryRepository ).save (originalEntry );
112196 }
113197
114198 private RecordLedgerEntryCommand command () {
@@ -145,4 +229,39 @@ private LedgerEntry savedEntry() {
145229 0L
146230 );
147231 }
232+
233+ private RecordLedgerEntryCommand adjustmentCommand () {
234+ return new RecordLedgerEntryCommand (
235+ "refund:payment-1:completed" ,
236+ LedgerTransactionType .REFUND ,
237+ "refund-1" ,
238+ "order-1" ,
239+ "user-1" ,
240+ "seller-1" ,
241+ new BigDecimal ("80000" ),
242+ "KRW" ,
243+ LedgerDirection .DEBIT ,
244+ "결제 환불"
245+ );
246+ }
247+
248+ private LedgerEntry adjustmentEntry () {
249+ return new LedgerEntry (
250+ "ledger-adjustment-1" ,
251+ "refund:payment-1:completed" ,
252+ LedgerTransactionType .REFUND ,
253+ "refund-1" ,
254+ "order-1" ,
255+ "user-1" ,
256+ "seller-1" ,
257+ new BigDecimal ("80000" ),
258+ "KRW" ,
259+ LedgerDirection .DEBIT ,
260+ LedgerStatus .POSTED ,
261+ "결제 환불" ,
262+ Instant .parse ("2026-08-15T00:01:00Z" ),
263+ null ,
264+ 0L
265+ );
266+ }
148267}
0 commit comments