This commit is contained in:
2026-08-07 19:23:40 +08:00
parent 37fec1e012
commit c698290106
39 changed files with 1897 additions and 13 deletions
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalCustomerDimensionConfigController.class)
class CustomerDimensionConfigControllerContractTest {
@Resource
private InternalCustomerDimensionConfigController internalController;
@MockBean
private CustomerDimensionConfigService service;
@BeforeEach
void initializeToken() {
ReflectionTestUtils.setField(internalController, "internalToken", "test-token");
internalController.initializeToken();
}
@Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
when(service.initializeDefault(8L, 100L, "registration-1"))
.thenReturn(new InitializeResponse(91L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true));
verify(service).initializeDefault(8L, 100L, "registration-1");
}
@Test
void platformControllerKeepsScopeAndExistingPermissions() throws Exception {
RequestMapping mapping = PlatformCustomerDimensionConfigController.class
.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).containsExactly("/api/platform/v1/first-leg/customer-dimension-config");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("get", com.baiyue.common.security.AuthenticatedUser.class, long.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:view')");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("update", com.baiyue.common.security.AuthenticatedUser.class,
long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.math.BigDecimal;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class ConfirmWeightResultRequestValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsConfirmationWithoutAdjustment() {
var request = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, null, null, null);
assertThat(validator.validate(request)).isEmpty();
assertThat(request.hasAdjustment()).isFalse();
}
@Test
void acceptsDocumentedWeightBoundaries() {
var minimum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("0.001"), "KG", "最小值");
var maximum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
Integer.MAX_VALUE, new BigDecimal("999999999999999.999"), "LB", "最大值");
assertThat(validator.validate(minimum)).isEmpty();
assertThat(validator.validate(maximum)).isEmpty();
assertThat(minimum.hasAdjustment()).isTrue();
assertThat(maximum.hasAdjustment()).isTrue();
}
@ParameterizedTest
@MethodSource("invalidRequests")
void rejectsInvalidFieldBoundaries(
InboundMeasurementRequests.ConfirmWeightResultRequest request,
String invalidField) {
assertThat(validator.validate(request))
.extracting(violation -> violation.getPropertyPath().toString())
.contains(invalidField);
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of(request(-1, "1.000", "KG", "原因"), "version"),
Arguments.of(request(0, "0.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "-1.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.0001", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1000000000000000.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.000", "KILOGRAMS", "原因"), "adjustedWeightUnit"),
Arguments.of(request(0, "1.000", "KG", "x".repeat(501)), "adjustmentReason")
);
}
private static InboundMeasurementRequests.ConfirmWeightResultRequest request(
int version, String value, String unit, String reason) {
return new InboundMeasurementRequests.ConfirmWeightResultRequest(
version, new BigDecimal(value), unit, reason);
}
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Delete;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigSqlContractTest {
@Test
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
String sql = new ClassPathResource("db/customer-dimension-config.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
}
@Test
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸计算配置表未创建")
.contains("uk_customer_dimension_config_tenant")
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("客户尺寸计算配置权限父节点企业客户不存在")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
.contains("平台管理员未获得客户尺寸计算配置读写权限")
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
}
@Test
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
String.class, java.math.BigDecimal.class)
.getAnnotation(Delete.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("edge_addition_cm = #{edgeAdditionCm}")
.contains("version = 0");
}
}
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionConfigService.class)
class CustomerDimensionConfigServiceTest {
@MockBean
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerDimensionConfigService service;
@Test
void initializesTwoCentimeterDefaultExactlyOnce() {
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
when(repository.find(8L)).thenReturn(null, persisted);
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result.createdByThisInitialization()).isTrue();
assertThat(result.configId()).isEqualTo(91L);
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-1"), anyLong());
}
@Test
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-2"), anyLong());
}
@Test
void rejectsStaleUpdateVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
assertThatThrownBy(() -> service.update(8L, 100L,
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
}
@Test
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
@Test
void editedConfigIsNotCompensatedByDelayedRollback() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
private static CustomerDimensionConfigEntity config(long id, long tenantId,
String edgeAddition, int version,
String initializationKey) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id);
value.setTenantId(tenantId);
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
value.setVersion(version);
value.setInitializationKey(initializationKey);
return value;
}
}
@@ -13,6 +13,8 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -123,6 +125,107 @@ class InboundMeasurementServiceTest {
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@ParameterizedTest
@CsvSource({
"kg,2.000",
"KGS,2.000",
"lb,0.907",
"LBS,0.907",
"' lbs ',0.907"
})
void confirmingAcceptsSupportedUnitAliases(String unit, String expectedKg) {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("2.000"), unit, "单位边界测试"));
assertThat(response.adjustedWeightUnit()).isEqualTo(
unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG");
assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.version()).isEqualTo(2);
}
@Test
void confirmingAcceptsSmallestRepresentableKgWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001");
}
@Test
void confirmingRejectsLbWeightThatRoundsToZeroKgBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("0.001"), "LB", "极小重量边界测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsOcrLbWeightThatRoundsToZeroKg() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "LB", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsUnsupportedUnitBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "G", "错误单位测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("重量单位仅支持 KG 或 LB");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -173,6 +276,87 @@ class InboundMeasurementServiceTest {
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingRejectsEveryPartialAdjustmentCombinationBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
List<InboundMeasurementRequests.ConfirmWeightResultRequest> partialRequests = List.of(
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, "KG", null),
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, "人工修正"),
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), " ", " ")
);
partialRequests.forEach(request -> assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员", request))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效重量、单位和修改原因"));
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(2, null, null, null));
assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(2);
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(1, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightStopsBeforeRecalculationWhenConfirmUpdateConflicts() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true, false);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "KG", "并发冲突测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, times(2)).updateWeight(weight);
verify(repository, times(1)).insertRevision(any(InboundWeightRevisionEntity.class));
verify(repository, never()).listWeights(anyLong(), anyLong());
verify(repository, never()).updateInbound(any());
}
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");