Skip to content

Commit 250292c

Browse files
committed
test: cover ledger integrity violation handling
1 parent 71fc635 commit 250292c

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

‎src/main/java/com/hwan/lessonplatformledger/ledger/adapter/LedgerEntryRepository.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@
1010
public interface LedgerEntryRepository extends CrudRepository<LedgerEntry, String> {
1111

1212
Optional<LedgerEntry> findByIdempotencyKey(String idempotencyKey);
13+
14+
Optional<LedgerEntry> findByEntryId(String entryId);
1315
}

‎src/main/java/com/hwan/lessonplatformledger/ledger/application/LedgerEntryService.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import lombok.RequiredArgsConstructor;
88
import org.springframework.dao.DataIntegrityViolationException;
99
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
1011

1112
import java.time.Instant;
1213
import java.util.UUID;
@@ -30,6 +31,19 @@ public RecordLedgerEntryResult recordEntry(RecordLedgerEntryCommand command) {
3031
}
3132
}
3233

34+
@Transactional
35+
public RecordLedgerEntryResult recordAdjustmentEntry(String entryId, RecordLedgerEntryCommand command) {
36+
LedgerEntry entry = ledgerEntryRepository.findByEntryId(entryId)
37+
.orElseThrow();
38+
39+
RecordLedgerEntryResult recordLedgerEntryResult = recordEntry(command);
40+
41+
entry.markReversed(recordLedgerEntryResult.entryId());
42+
ledgerEntryRepository.save(entry);
43+
44+
return recordLedgerEntryResult;
45+
}
46+
3347
private LedgerEntry createEntry(RecordLedgerEntryCommand command) {
3448
LedgerEntry newLedger = LedgerEntry.recordEntry(
3549
UUID.randomUUID().toString(),
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.hwan.lessonplatformledger.ledger.application;
2+
3+
import com.hwan.lessonplatformledger.ledger.adapter.LedgerEntryRepository;
4+
import com.hwan.lessonplatformledger.ledger.domain.LedgerDirection;
5+
import com.hwan.lessonplatformledger.ledger.domain.LedgerEntry;
6+
import com.hwan.lessonplatformledger.ledger.domain.LedgerStatus;
7+
import com.hwan.lessonplatformledger.ledger.domain.LedgerTransactionType;
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.data.jdbc.test.autoconfigure.DataJdbcTest;
13+
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
14+
import org.springframework.dao.DataIntegrityViolationException;
15+
import org.springframework.test.context.ActiveProfiles;
16+
import org.springframework.transaction.PlatformTransactionManager;
17+
import org.springframework.transaction.TransactionDefinition;
18+
import org.springframework.transaction.TransactionStatus;
19+
import org.springframework.transaction.annotation.Propagation;
20+
import org.springframework.transaction.annotation.Transactional;
21+
import org.springframework.transaction.support.DefaultTransactionDefinition;
22+
23+
import java.math.BigDecimal;
24+
import java.time.Instant;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.assertj.core.api.Assertions.assertThatCode;
28+
29+
@DataJdbcTest
30+
@ActiveProfiles("test")
31+
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
32+
@DisplayName("DataIntegrityViolationException 트랜잭션 테스트")
33+
class DataIntegrityViolationTransactionTest {
34+
35+
private static final String IDEMPOTENCY_KEY = "payment:payment-1:completed";
36+
37+
@Autowired
38+
private LedgerEntryRepository ledgerEntryRepository;
39+
40+
@Autowired
41+
private PlatformTransactionManager transactionManager;
42+
43+
@BeforeEach
44+
void setUp() {
45+
ledgerEntryRepository.deleteAll();
46+
ledgerEntryRepository.save(entry("ledger-1", IDEMPOTENCY_KEY));
47+
}
48+
49+
@Test
50+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
51+
@DisplayName("유니크 충돌을 같은 트랜잭션에서 catch하면 커밋 시 롤백된다")
52+
void rollsBackWhenDataIntegrityViolationIsCaughtInsideSameTransaction() {
53+
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
54+
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
55+
TransactionStatus status = transactionManager.getTransaction(definition);
56+
57+
try {
58+
ledgerEntryRepository.save(entry("ledger-duplicate", IDEMPOTENCY_KEY));
59+
} catch (DataIntegrityViolationException exception) {
60+
// 예외를 catch해도 이미 rollback-only로 표시된 트랜잭션은 정상 커밋할 수 없다.
61+
assertThat(ledgerEntryRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)).isPresent();
62+
status.setRollbackOnly();
63+
}
64+
65+
assertThatCode(() -> transactionManager.commit(status))
66+
.doesNotThrowAnyException();
67+
68+
assertThat(ledgerEntryRepository.count()).isEqualTo(1);
69+
}
70+
71+
private static LedgerEntry entry(String entryId, String idempotencyKey) {
72+
return new LedgerEntry(
73+
entryId,
74+
idempotencyKey,
75+
LedgerTransactionType.PAYMENT,
76+
"payment-1",
77+
"order-1",
78+
"user-1",
79+
"seller-1",
80+
new BigDecimal("80000"),
81+
"KRW",
82+
LedgerDirection.CREDIT,
83+
LedgerStatus.POSTED,
84+
"강의 결제",
85+
Instant.parse("2026-08-15T00:00:00Z"),
86+
null,
87+
null
88+
);
89+
}
90+
}

‎src/test/java/com/hwan/lessonplatformledger/ledger/application/LedgerEntryServiceIntegrationTest.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,27 @@ void rejectsDuplicateIdempotencyKeyAtDatabaseLevel() {
135135
.isInstanceOf(DataIntegrityViolationException.class);
136136
}
137137

138+
@Test
139+
@DisplayName("보정 원장을 저장하고 기존 원장을 역분개 상태로 갱신한다")
140+
void recordsAdjustmentAndReversesOriginalEntry() {
141+
RecordLedgerEntryResult original = ledgerEntryService.recordEntry(
142+
command("payment:payment-1:completed")
143+
);
144+
145+
RecordLedgerEntryResult adjustment = ledgerEntryService.recordAdjustmentEntry(
146+
original.entryId(), adjustmentCommand()
147+
);
148+
149+
LedgerEntry reloadedOriginal = ledgerEntryRepository.findById(original.entryId()).orElseThrow();
150+
LedgerEntry savedAdjustment = ledgerEntryRepository.findById(adjustment.entryId()).orElseThrow();
151+
152+
assertThat(savedAdjustment.getTransactionType()).isEqualTo(LedgerTransactionType.REFUND);
153+
assertThat(savedAdjustment.getDirection()).isEqualTo(LedgerDirection.DEBIT);
154+
assertThat(reloadedOriginal.getStatus()).isEqualTo(LedgerStatus.REVERSED);
155+
assertThat(reloadedOriginal.getReversedEntryId()).isEqualTo(savedAdjustment.getEntryId());
156+
assertThat(ledgerEntryRepository.count()).isEqualTo(2);
157+
}
158+
138159
private RecordLedgerEntryCommand command(String idempotencyKey) {
139160
return new RecordLedgerEntryCommand(
140161
idempotencyKey,
@@ -149,4 +170,19 @@ private RecordLedgerEntryCommand command(String idempotencyKey) {
149170
"강의 결제"
150171
);
151172
}
173+
174+
private RecordLedgerEntryCommand adjustmentCommand() {
175+
return new RecordLedgerEntryCommand(
176+
"refund:payment-1:completed",
177+
LedgerTransactionType.REFUND,
178+
"refund-1",
179+
"order-1",
180+
"user-1",
181+
"seller-1",
182+
new BigDecimal("80000"),
183+
"KRW",
184+
LedgerDirection.DEBIT,
185+
"결제 환불"
186+
);
187+
}
152188
}

‎src/test/java/com/hwan/lessonplatformledger/ledger/application/LedgerEntryServiceTest.java‎

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import java.util.UUID;
2323

2424
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2526
import static org.mockito.ArgumentMatchers.any;
2627
import static org.mockito.Mockito.never;
28+
import static org.mockito.Mockito.times;
2729
import static org.mockito.Mockito.verify;
2830
import static org.mockito.Mockito.when;
2931
import 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

Comments
 (0)