Files
baiyue-firstleg/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java
T

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();
}
}