213 lines
12 KiB
Java
213 lines
12 KiB
Java
package com.baiyue.finance;
|
|
|
|
import com.baiyue.common.event.EventEnvelope;
|
|
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
|
import com.baiyue.common.event.EventPayloadHash;
|
|
import com.baiyue.common.event.EventTypes;
|
|
import com.baiyue.common.event.WalletCommandRequestedV1;
|
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
|
import com.baiyue.finance.service.FinanceEventException;
|
|
import com.baiyue.finance.service.FinanceWalletService;
|
|
import com.baiyue.finance.service.WalletCommandEventService;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.transaction.TransactionStatus;
|
|
import org.springframework.transaction.PlatformTransactionManager;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.UUID;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
class WalletCommandEventServiceTest {
|
|
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
|
private final FinanceWalletService wallet = mock(FinanceWalletService.class);
|
|
private final PlatformTransactionManager transactions = mock(PlatformTransactionManager.class);
|
|
private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions);
|
|
|
|
@BeforeEach
|
|
void executeTransactionCallbacks() {
|
|
when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class));
|
|
}
|
|
|
|
@Test
|
|
void rejectsTamperedEnvelopeBeforeOpeningWalletTransaction() {
|
|
EventEnvelope valid = envelope("FREEZE", 1L, BigDecimal.TEN);
|
|
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
|
|
valid.occurredAt(), valid.tenantId(), valid.aggregateType(),
|
|
valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(),
|
|
valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64));
|
|
assertThatThrownBy(() -> service.consume(tampered)).isInstanceOf(FinanceEventException.class)
|
|
.hasMessageContaining("摘要与实际载荷不一致");
|
|
verifyNoInteractions(jdbc, wallet);
|
|
}
|
|
|
|
@Test
|
|
void freezesWalletAndPublishesCompletedEvent() {
|
|
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50"));
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
|
|
.thenReturn(List.of(envelope.payloadHash()));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn(
|
|
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"),
|
|
new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null,
|
|
null, null, null));
|
|
|
|
assertThat(service.consume(envelope).eventId()).isEqualTo(envelope.eventId());
|
|
|
|
verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request ->
|
|
request.amount().compareTo(new BigDecimal("88.50")) == 0
|
|
&& request.businessNo().equals("PO-1")));
|
|
verify(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
|
|
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="),
|
|
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
|
|
}
|
|
|
|
@Test
|
|
void settledWalletHoldMarksMatchingReceivableSourceSettled() {
|
|
EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50"));
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
|
|
.thenReturn(List.of(envelope.payloadHash()));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
|
|
eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L,
|
|
"OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"),
|
|
new BigDecimal("8.50"), "SETTLED", 71L, 72L, null, null, null));
|
|
|
|
service.consume(envelope);
|
|
|
|
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L));
|
|
}
|
|
|
|
@Test
|
|
void transientWalletFailureMustNotBecomeAFinalResult() {
|
|
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString()))
|
|
.thenReturn(List.of(event.payloadHash()));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
var failure = new org.springframework.dao.TransientDataAccessResourceException("temporary database failure");
|
|
when(wallet.freeze(anyLong(), anyString(), any())).thenThrow(failure);
|
|
|
|
assertThatThrownBy(() -> service.consume(event)).isSameAs(failure);
|
|
verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
|
|
}
|
|
|
|
@Test
|
|
void actualSettlementReportsActualChargeInsteadOfOriginalEstimate() throws Exception {
|
|
EventEnvelope event = envelope("SETTLE_ACTUAL", 31L, new BigDecimal("60.00"));
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
|
|
.thenReturn(List.of(event.payloadHash()));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
when(wallet.settleActual(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
|
|
eq("freeze-procurement-1"), argThat((BigDecimal amount) -> amount.compareTo(new BigDecimal("60.00")) == 0), any())).thenReturn(
|
|
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("100.00"),
|
|
new BigDecimal("100.00"), BigDecimal.ZERO, "SETTLED", 71L, 72L, null, null, null));
|
|
service.consume(event);
|
|
var resultJson = org.mockito.ArgumentCaptor.forClass(String.class);
|
|
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
|
|
resultJson.capture(), eq(event.eventId()));
|
|
JsonNode result = mapper.readTree(resultJson.getValue());
|
|
assertThat(result.path("amount").decimalValue()).isEqualByComparingTo("60.00");
|
|
assertThat(result.path("holdStatus").asText()).isEqualTo("SETTLED");
|
|
assertThat(result.path("walletHoldId").asText()).isEqualTo("9");
|
|
assertThat(result.path("walletLedgerId").asText()).isEqualTo("72");
|
|
verify(jdbc).update(contains("f.amount=-l.amount"), eq(9L));
|
|
verify(wallet, never()).release(anyLong(), anyString(), anyString(), anyString(), any());
|
|
}
|
|
|
|
@Test
|
|
void actualSettlementRejectsZeroAndMissingAmountsBeforeOpeningTransaction() {
|
|
for (BigDecimal amount : new BigDecimal[]{null, BigDecimal.ZERO, new BigDecimal("0.001")}) {
|
|
assertThatThrownBy(() -> service.consume(envelope("SETTLE_ACTUAL", 31L, amount)))
|
|
.isInstanceOf(FinanceEventException.class);
|
|
}
|
|
verifyNoInteractions(jdbc, wallet, transactions);
|
|
}
|
|
|
|
@Test
|
|
void losingInboxInsertMustRecheckThePersistedHash() {
|
|
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString()))
|
|
.thenAnswer(invocation -> ((String) invocation.getArgument(0)).contains("FOR UPDATE")
|
|
? List.of("a-different-persisted-hash") : List.of());
|
|
when(jdbc.update(startsWith("INSERT INTO fin_wallet_command_inbox"), any(Object[].class)))
|
|
.thenThrow(new org.springframework.dao.DuplicateKeyException("another request won"));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
|
|
assertThatThrownBy(() -> service.consume(event))
|
|
.isInstanceOf(FinanceEventException.class)
|
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002");
|
|
verifyNoInteractions(wallet);
|
|
}
|
|
|
|
@Test
|
|
void rejectsCustomerMismatchBeforeTouchingWallet() {
|
|
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
|
envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
|
|
envelope.occurredAt(), "32", envelope.aggregateType(),
|
|
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(),
|
|
envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
|
|
|
|
EventEnvelope mismatched = envelope;
|
|
assertThatThrownBy(() -> service.consume(mismatched))
|
|
.isInstanceOf(FinanceEventException.class)
|
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007");
|
|
verifyNoInteractions(wallet);
|
|
}
|
|
|
|
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) {
|
|
return envelope(operation, customerId, amount, null);
|
|
}
|
|
|
|
@Test
|
|
void refundRequiresOriginalConsumptionBeforeAnyWalletCall() {
|
|
assertThatThrownBy(() -> service.consume(envelope("REFUND", 31L, BigDecimal.TEN)))
|
|
.isInstanceOf(FinanceEventException.class);
|
|
verifyNoInteractions(jdbc, wallet);
|
|
}
|
|
|
|
@Test
|
|
void refundHasItsOwnLedgerReferenceAndNeverPretendsToSettleAHold() throws Exception {
|
|
EventEnvelope event = envelope("REFUND", 31L, BigDecimal.TEN, "72");
|
|
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
|
|
.thenReturn(List.of(event.payloadHash()));
|
|
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
|
var refunded = new com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry(73L, "AC-TEST", "REFUND",
|
|
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.ZERO,
|
|
BigDecimal.ZERO, null, "BUSINESS_REFUND", "PO-1", java.time.LocalDate.now(), null,
|
|
"AC-ORIGINAL", "业务系统", "采购快递费", null);
|
|
when(wallet.refundConsumption(eq(31L), eq(72L), eq("PO-1"), eq("freeze-procurement-1"),
|
|
any(), eq(0L), anyString(), anyString())).thenReturn(refunded);
|
|
service.consume(event);
|
|
var capture = org.mockito.ArgumentCaptor.forClass(String.class);
|
|
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
|
|
capture.capture(), eq(event.eventId()));
|
|
JsonNode result = mapper.readTree(capture.getValue());
|
|
assertThat(result.path("walletLedgerId").asText()).isEqualTo("73");
|
|
assertThat(result.path("walletHoldId").isNull()).isTrue();
|
|
assertThat(result.path("holdStatus").asText()).isEqualTo("REFUNDED");
|
|
verify(wallet, never()).settle(anyLong(), anyString(), anyString(), anyString(), any());
|
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
|
}
|
|
|
|
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount, String originalLedgerId) {
|
|
WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId,
|
|
"OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费",
|
|
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"), originalLedgerId);
|
|
JsonNode payload = mapper.valueToTree(command);
|
|
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
|
|
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
|
|
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
|
|
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
|
|
}
|
|
}
|