feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
+2
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.controller.AppointmentSourceCreateController;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
@@ -19,7 +20,7 @@ class AppointmentPlanningAuthorizationTest {
|
||||
@Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentSourceCreateController.class);
|
||||
var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")),
|
||||
var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")),
|
||||
"100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key");
|
||||
login(LIST,CARGO,WRITE);
|
||||
assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request));
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.delivery.fleet;
|
||||
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetPoolDeliveryAdapterTest {
|
||||
private final DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
|
||||
private final FleetPoolDeliveryAdapter adapter=new FleetPoolDeliveryAdapter(evidence);
|
||||
private final LocalDateTime signed=LocalDateTime.of(2026,10,12,12,0);
|
||||
private void shipment(int total,long signatures,List<Pick> picks,List<Returned> returns){
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
|
||||
when(evidence.deliveries("9",70)).thenReturn(List.of(new Delivery(71,70,"CODE:ONT8",total,signatures,signed,signed.minusHours(1))));
|
||||
when(evidence.picks("9",70)).thenReturn(picks);when(evidence.returns("9",70)).thenReturn(returns);
|
||||
}
|
||||
private Pick pick(long id,long source,int quantity){return new Pick(id,70,"CODE:ONT8",source,quantity,id,null,null,null);}
|
||||
@Test void completeActualSourceIsSignedButPartialSignatureIsNot(){
|
||||
shipment(4,2,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse();
|
||||
shipment(4,4,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue();
|
||||
}
|
||||
@Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedSource(){
|
||||
shipment(8,6,List.of(pick(1,77,4),pick(2,78,4)),List.of(new Returned(9,71,78,2,signed.minusMinutes(5))));
|
||||
assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue();
|
||||
}
|
||||
@Test void thisSourceReturnCannotBeCountedAsWholeSignature(){
|
||||
shipment(4,2,List.of(pick(1,77,4)),List.of(new Returned(9,71,77,2,signed.minusMinutes(5))));
|
||||
assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse();
|
||||
}
|
||||
@Test void missingOrDuplicatedDispatchEvidenceIsUnproved(){
|
||||
for(var ids:List.of(List.<Long>of(),List.of(70L,70L),List.of(0L))){
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(ids);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse();
|
||||
}
|
||||
verify(evidence,never()).deliveries(anyString(),anyLong());
|
||||
}
|
||||
@Test void missingPickOrReturnHistoryIsUnproved(){
|
||||
shipment(4,4,List.of(),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse();
|
||||
shipment(4,4,List.of(pick(1,77,4)),null);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse();
|
||||
}
|
||||
}
|
||||
+20
-32
@@ -1,15 +1,11 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.mapper.*;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||
import com.baiyue.lastleg.trucking.mapper.*;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
@@ -26,15 +22,14 @@ class FleetPoolOrderBridgeTest {
|
||||
LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class);
|
||||
UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class);
|
||||
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
|
||||
LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
|
||||
DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
|
||||
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
|
||||
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of());
|
||||
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
|
||||
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
|
||||
@@ -66,16 +61,13 @@ class FleetPoolOrderBridgeTest {
|
||||
doThrow(new IllegalStateException("appointment binding conflict")).when(appointmentSources).materialized("9","FB-7",77L);
|
||||
assertThatThrownBy(this::incoming).isInstanceOf(IllegalStateException.class).hasMessageContaining("appointment binding conflict");
|
||||
}
|
||||
FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of(
|
||||
new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE),
|
||||
new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);}
|
||||
Receipt receipt(){return new Receipt("WT-7","92","FBA:ONT8","READY_FOR_DISPATCH",List.of(
|
||||
new ReceivedPallet("101",2),new ReceivedPallet("102",2)),4);}
|
||||
void dispatch(String type){service.confirm(input(type),"op");incoming();service.receiving("9","WT-7","pda");
|
||||
service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");}
|
||||
DeliveryOrder delivery(int signed){var d=new DeliveryOrder();d.setId(71L);d.setDispatchOrderId(70L);d.setDeliveryGroupKey("CODE:ONT8");d.setCustomerId("9");d.setDispatchNo("DISPATCH-7");d.setDeliveryNo("DELIVERY-7");
|
||||
d.setStatus("DELIVERED");d.setPackageCount(4);d.setSignedPackages(signed);d.setSignedAt(LocalDateTime.now());
|
||||
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",4,(long)signed,d.getSignedAt(),d.getSignedAt().minusHours(1))));
|
||||
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null)));
|
||||
return d;}
|
||||
SignedDelivery delivery(int signed){
|
||||
when(evidence.fullySigned("9","WT-7",77,4)).thenReturn(signed>=4);
|
||||
return new SignedDelivery("9","DISPATCH-7","DELIVERY-7",true,LocalDateTime.now());}
|
||||
@Test void looseboardConfirmationCreatesOnlyCommercialRecordAndReplaysWithoutAnotherOrder(){
|
||||
var result=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
assertThat(result.businessOrderNo()).isEqualTo("LOCAL-001");assertThat(looseOrder.getLastlegOrderId()).isNull();
|
||||
@@ -130,9 +122,9 @@ class FleetPoolOrderBridgeTest {
|
||||
@Test void incomingBlocksCancellationAndWrongReceiptIdentityOrDuplicatePhysicalPallets(){
|
||||
service.confirm(input("US_TRUCK"),"op");incoming();
|
||||
assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","取消")).hasMessageContaining("已登记来货");
|
||||
var r=receipt();var wrong=new FleetBookingIncomingGateway.Evidence("OTHER",r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),r.pallets(),4);
|
||||
var r=receipt();var wrong=new Receipt("OTHER",r.warehouseId(),r.destinationKey(),r.status(),r.pallets(),4);
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,wrong,3,"op")).hasMessageContaining("来源");
|
||||
var duplicate=new FleetBookingIncomingGateway.Evidence(r.incomingNo(),r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),List.of(r.pallets().get(0),r.pallets().get(0)),4);
|
||||
var duplicate=new Receipt(r.incomingNo(),r.warehouseId(),r.destinationKey(),r.status(),List.of(r.pallets().get(0),r.pallets().get(0)),4);
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,duplicate,3,"op")).hasMessageContaining("重复");
|
||||
}
|
||||
@Test void actualDepartureAndFullSignatureDriveLooseboardExactlyOnce(){
|
||||
@@ -157,22 +149,15 @@ class FleetPoolOrderBridgeTest {
|
||||
assertThatThrownBy(()->service.confirm(missing,"op")).hasMessageContaining("独立实托数");
|
||||
verifyNoInteractions(trucks,loose,links);
|
||||
}
|
||||
@Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedCommercialOrder(){
|
||||
@Test void deliveryOwnedProofCompletesOnlyThisCommercialSource(){
|
||||
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
var d=delivery(6);d.setPackageCount(8);
|
||||
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",8,6L,d.getSignedAt(),d.getSignedAt().minusHours(1))));
|
||||
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null),
|
||||
new SourceDeliveryCompletionPolicy.Pick(2,70,"CODE:ONT8",78,4,2L,null,null,null)));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,78,2,d.getSignedAt().minusMinutes(5))));
|
||||
service.signed(d,Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");
|
||||
service.signed(delivery(4),Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());
|
||||
assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");verify(evidence).fullySigned("9","WT-7",77,4);
|
||||
}
|
||||
@Test void thisSourceReturnOrMissingHistoryCannotBeCountedAsWholeSignature(){
|
||||
@Test void missingDeliveryOwnedProofCannotCompleteCommercialOrder(){
|
||||
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
var d=delivery(2);
|
||||
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,77,2,d.getSignedAt().minusMinutes(5))));
|
||||
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
d=delivery(4);when(evidence.dispatchIds("9",77)).thenReturn(List.of());
|
||||
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
service.signed(delivery(2),Map.of(77L,4L),"op",LocalDateTime.now());
|
||||
assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
@Test void bindingDoesNotMeanDepartureAndCannotCompleteBeforeActualDeparture(){
|
||||
dispatch("US_TRUCK");assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH");
|
||||
@@ -185,6 +170,9 @@ class FleetPoolOrderBridgeTest {
|
||||
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
|
||||
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName()))
|
||||
.noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory"));
|
||||
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName()))
|
||||
.noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip")
|
||||
||name.equals("com.baiyue.lastleg.service"));
|
||||
}
|
||||
@Test void poolOnlyCallbacksCannotSucceedWithoutTheirCommercialLink(){
|
||||
assertThatThrownBy(()->service.acceptedReceipt(7,receipt(),1,"op")).hasMessageContaining("缺少唯一商业订单关联");
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetPoolOrderNumberGeneratorTest {
|
||||
private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator();
|
||||
@Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){
|
||||
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567");
|
||||
assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001");
|
||||
}
|
||||
@Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){
|
||||
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009");
|
||||
assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009");
|
||||
assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -69,7 +69,7 @@ class FbaAppointmentPersistenceTest {
|
||||
new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓","FBA:ONT8","ONT8",secondPackages,"ORDER:1",true,List.of()));
|
||||
}
|
||||
private static SourceFbaAppointmentRequest request(String key) {
|
||||
return new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")),
|
||||
return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")),
|
||||
"100","ONT8",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),key);
|
||||
}
|
||||
@Test void failureAfterHeaderAndFirstItemRollsBackTheCommandAndEntireAggregate() {
|
||||
@@ -169,7 +169,7 @@ class FbaAppointmentPersistenceTest {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var linker=context.getBean(AppointmentSourceLinker.class);
|
||||
var before=new AppointmentSourceService.Source("BOOKING:B-1","C",null,null,"B-1",null,"100","美国仓","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of());
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(before));
|
||||
var request=new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming");
|
||||
var request=new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming");
|
||||
var saved=service.createFromSources("operator",request);
|
||||
assertNull(saved.items().get(0).orderId());
|
||||
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->linker.materialized("C","B-1",100L));
|
||||
@@ -221,7 +221,7 @@ class FbaAppointmentPersistenceTest {
|
||||
@Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);}
|
||||
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
|
||||
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
|
||||
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new AppointmentSourceLinker(orders,items,bookings);}
|
||||
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
|
||||
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -55,15 +55,26 @@ class InboundFbaAppointmentServiceTest {
|
||||
List<AppointmentCoverageService.SourceRef> refs(String... keys) {return Arrays.stream(keys).map(key->new AppointmentCoverageService.SourceRef(cargo.get(key).customerId(),key,cargo.get(key).destinationKey())).toList();}
|
||||
FbaAppointmentDetails pending(String remark) {return new FbaAppointmentDetails(LocalDate.of(2026,10,15),null,null,null,null,null,"PENDING",remark,null,null);}
|
||||
FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(LocalDate.of(2026,10,14),null,null,"EXT-ONT8-1",at,at.plusHours(1),"SUCCESS","已核实",null,null);}
|
||||
SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(refs(sources),"100","ONT8",details,key);}
|
||||
List<SourceFbaAppointmentRequest.SourceRef> requestRefs(String... keys) {return refs(keys).stream().map(r->new SourceFbaAppointmentRequest.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList();}
|
||||
SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(requestRefs(sources),"100","ONT8",details,key);}
|
||||
FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));}
|
||||
|
||||
@Test void sourceRequestKeepsWireShapeAndPassesAllSourceFieldsToResolution() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var input=request("source-json",pending(null),"ORDER:1","ORDER:2");
|
||||
assertEquals(json.valueToTree(refs("ORDER:1","ORDER:2")),json.valueToTree(input).get("sources"));
|
||||
var decoded=json.readValue(json.writeValueAsString(input),SourceFbaAppointmentRequest.class);
|
||||
assertEquals(input,decoded);
|
||||
service.createFromSources("operator",decoded);
|
||||
verify(sources,times(2)).resolveBatch(refs("ORDER:1","ORDER:2"));
|
||||
}
|
||||
|
||||
@Test void refusesOriginOrDestinationChangedAfterSelection() {
|
||||
var intent=request("origin-changed",pending(null),"ORDER:1");
|
||||
add("ORDER:1","A",1L,"200","ONT8",10);
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",intent)).getStatusCode().value());
|
||||
add("ORDER:1","A",1L,"100","LAX9",10);
|
||||
var changed=new SourceFbaAppointmentRequest(refs("ORDER:1"),"100","ONT8",pending(null),"destination-changed");
|
||||
var changed=new SourceFbaAppointmentRequest(requestRefs("ORDER:1"),"100","ONT8",pending(null),"destination-changed");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",changed)).getStatusCode().value());
|
||||
verify(headers,never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.lastleg.schema;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfoperatedAnalyticsSchemaContractTest {
|
||||
private static final Pattern TABLE = Pattern.compile(
|
||||
"(?s)CREATE TABLE (ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;");
|
||||
|
||||
private static Map<String, String> tables(String sql) {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
var matcher = TABLE.matcher(sql);
|
||||
while (matcher.find()) {
|
||||
assertNull(result.put(matcher.group(1), matcher.group()), "duplicate table definition");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception {
|
||||
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
|
||||
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
|
||||
assertEquals(10, source.size());
|
||||
assertEquals(source, deployment);
|
||||
for (var entry : source.entrySet()) {
|
||||
var ddl = entry.getValue();
|
||||
assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey());
|
||||
assertTrue(ddl.contains("id BIGINT NOT NULL"));
|
||||
assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL"));
|
||||
assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL"));
|
||||
assertFalse(ddl.contains("AUTO_INCREMENT"));
|
||||
assertFalse(ddl.contains("tenant_id"));
|
||||
assertFalse(ddl.contains("FOREIGN KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSourceTimeAndStockCannotBecomeZeroOrEligibleSalesByDefault() throws Exception {
|
||||
var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
|
||||
for (var table : new String[]{"ll_selfop_sync_scope", "ll_selfop_outbound_order"}) {
|
||||
assertTrue(ddl.get(table).contains("source_timezone VARCHAR(64) NULL COMMENT"));
|
||||
}
|
||||
var stock = ddl.get("ll_selfop_inventory_snapshot");
|
||||
for (var column : new String[]{"physical_quantity", "available_quantity", "frozen_quantity", "transit_quantity"}) {
|
||||
assertTrue(stock.contains(column + " DECIMAL(20,6) NULL COMMENT"));
|
||||
}
|
||||
assertTrue(ddl.get("ll_selfop_outbound_order").contains("sales_eligible TINYINT NOT NULL DEFAULT 0"));
|
||||
assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_status = 100 AND source_timezone IS NOT NULL"));
|
||||
assertTrue(ddl.get("ll_selfop_outbound_item").contains("CHECK (actual_quantity >= 0)"));
|
||||
// Negative observed stock must be retained as INVALID, never rejected/coerced by a quantity CHECK.
|
||||
assertFalse(stock.contains("CHECK (available_quantity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void durableRecoveryAndImmutableVersionKeysArePresent() throws Exception {
|
||||
var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
|
||||
var task = ddl.get("ll_selfop_sync_task");
|
||||
for (var column : new String[]{"apply_state", "affected_warehouse_codes_json", "observation_baseline",
|
||||
"routing_manifest_json", "request_hash", "apply_completed_at_utc", "task_no", "execution_purpose",
|
||||
"validation_root_task_id", "validation_budget_json", "result_published_run_id", "progress_json",
|
||||
"progress_updated_at_utc"}) {
|
||||
assertTrue(task.contains(" " + column + " "), column);
|
||||
}
|
||||
assertTrue(task.contains("UNIQUE KEY uk_selfop_task_no (task_no)"));
|
||||
var account = ddl.get("ll_selfop_outbound_discovery_scope");
|
||||
for (var column : new String[]{"api_budget_json", "api_cooldown_until_utc", "api_pause_reason"}) {
|
||||
assertTrue(account.contains(" " + column + " "), column);
|
||||
}
|
||||
var run = ddl.get("ll_selfop_sync_run");
|
||||
for (var column : new String[]{"execution_purpose", "build_started_at_utc", "superseded_at_utc",
|
||||
"progress_snapshot_json", "heartbeat_at_utc", "inventory_daily_sample_date", "inventory_sample_rule_version"}) {
|
||||
assertTrue(run.contains(" " + column + " "), column);
|
||||
}
|
||||
assertTrue(run.contains("uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date)"));
|
||||
assertTrue(run.contains("CONSTRAINT chk_selfop_inventory_daily CHECK"));
|
||||
assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code)"));
|
||||
assertTrue(ddl.get("ll_selfop_outbound_order").contains("idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id)"));
|
||||
assertTrue(ddl.get("ll_selfop_sync_stage").contains("stage_batch_id,object_type,object_key_hash"));
|
||||
assertTrue(ddl.get("ll_selfop_sync_stage").contains("observation_sequence IS NOT NULL"));
|
||||
assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_account,customer_code,delivery_no"));
|
||||
assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date"));
|
||||
assertTrue(ddl.get("ll_selfop_sku_profile").contains("profile_run_id,source_account,customer_code,warehouse_code,sku_code"));
|
||||
assertTrue(ddl.get("ll_selfop_inventory_snapshot").contains("snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readAndSyncPermissionsAreIndependentWithThreeAuthorizedAdminMenus() throws Exception {
|
||||
var seed = Files.readString(Path.of("../iam/src/main/resources/db/selfoperated-analytics-permissions.sql"));
|
||||
var unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertTrue(unified.contains(seed.strip()));
|
||||
assertTrue(seed.contains("platform:self-operated-analytics:list"));
|
||||
assertTrue(seed.contains("platform:self-operated-analytics:sync"));
|
||||
for (var route : new String[]{"/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) {
|
||||
assertTrue(seed.contains(route));
|
||||
}
|
||||
assertTrue(seed.contains("'API',5118100,'platform:self-operated-analytics:list'"));
|
||||
assertTrue(seed.contains("'API',5118130,'platform:self-operated-analytics:sync'"));
|
||||
assertFalse(seed.contains("'MERCHANT'"));
|
||||
assertFalse(seed.contains("INSERT IGNORE"));
|
||||
assertFalse(seed.contains("ON DUPLICATE KEY"));
|
||||
assertTrue(seed.contains("(4000001,5118000,0,"));
|
||||
assertTrue(seed.contains("(4000001,5118001,0,"));
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.client;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.request.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
|
||||
|
||||
class RestLingxingAnalyticsClientTest {
|
||||
private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties();
|
||||
private final RestClient.Builder builder = RestClient.builder().baseUrl("http://synthetic-integration");
|
||||
private final MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
private final SelfopEgressService egress = mock(SelfopEgressService.class);
|
||||
private final RestLingxingAnalyticsClient client = new RestLingxingAnalyticsClient(builder.build(), properties, egress);
|
||||
private static SelfopRequestContext context(String account) { return new SelfopRequestContext(account,"BUSINESS",1L,"selfop-1","grant",1L,2L); }
|
||||
private static final String PREFIX = "http://synthetic-integration/internal/integrations/lingxing/wms";
|
||||
|
||||
private void enable() {
|
||||
|
||||
properties.setCollectionMode("BUSINESS");
|
||||
when(egress.reserve(any(),any(),anyInt(),any(),any(),any())).thenAnswer(i -> {
|
||||
SelfopRequestContext c=i.getArgument(0);
|
||||
return new SelfopEgressDtos.EgressContext(c.sourceAccount(),c.executionPurpose(),"1",c.sessionId(),"synthetic-attempt",c.exclusiveGrantId());
|
||||
});
|
||||
properties.setSourceAccount("synthetic-account");
|
||||
properties.setInternalServiceToken("synthetic-token");
|
||||
}
|
||||
@Test void disabledByDefaultAndAccountBindingFailBeforeTransport() {
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED");
|
||||
enable();
|
||||
assertThatThrownBy(() -> client.warehouses(context("other-account"), 1, 100)).hasMessage("SOURCE_ACCOUNT_MISMATCH");
|
||||
properties.setInternalServiceToken("");
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("INTERNAL_TOKEN_UNCONFIGURED");
|
||||
server.verify();
|
||||
}
|
||||
@Test void oneWarehousePagePreservesTimezoneAsClueAndStringLongMetadata() {
|
||||
enable();
|
||||
expect("/warehouses/query", "{\"current\":1,\"size\":100}", """
|
||||
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":"1","records":[
|
||||
{"whCode":"WH-A","whNameCn":"A","warehouseType":1,"status":0,"timeZone":"America/New_York"}]}}
|
||||
""");
|
||||
var page = client.warehouses(context("synthetic-account"), 1, 100);
|
||||
assertThat(page.total()).isEqualTo(1L);
|
||||
assertThat(page.records().get(0).timeZone()).isEqualTo("America/New_York");
|
||||
assertThat(properties.getSourceQueryTimezone()).isNull();
|
||||
server.verify();
|
||||
}
|
||||
@Test void discoveryUsesNormalizedEmptyMetadataAndDoesNotSendWarehouseFilter() {
|
||||
enable();
|
||||
expect("/outbound-orders/query", """
|
||||
{"startTime":"2026-10-01T00:00:00","endTime":"2026-10-02T00:00:00","current":1,"size":100}
|
||||
""", "{\"code\":\"200\",\"data\":{\"current\":1,\"size\":100,\"pages\":0,\"total\":\"0\",\"records\":[]}}");
|
||||
assertThat(client.discover(context("synthetic-account"), LocalDateTime.of(2026,10,1,0,0),
|
||||
LocalDateTime.of(2026,10,2,0,0), 1, 100).records()).isEmpty();
|
||||
server.verify();
|
||||
}
|
||||
@Test void locateHasNoTimeOrStatusAndReturnsCurrentCancelledIdentity() {
|
||||
enable();
|
||||
expect("/outbound-orders/locate", "{\"deliveryNo\":\"DO-1\",\"current\":1,\"size\":100}", """
|
||||
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[
|
||||
{"deliveryNo":"DO-1","sourceNo":"SRC-1","customerCode":"C","whCode":"WH-B","status":99,"outboundTime":null}]}}
|
||||
""");
|
||||
var summary = client.locate(context("synthetic-account"), "DO-1", 1, 100).records().get(0);
|
||||
assertThat(summary.sourceStatus()).isEqualTo(99);
|
||||
assertThat(summary.warehouseCode()).isEqualTo("WH-B");
|
||||
server.verify();
|
||||
}
|
||||
@Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() {
|
||||
enable();
|
||||
expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """
|
||||
{"code":"200","data":{"deliveryNo":"DO-1","customerCode":"C","whCode":"WH-A","status":100,
|
||||
"orderCreateTime":"2025-01-01 00:00:00","outboundTime":"2026-10-01 00:00:00",
|
||||
"skuVOList":[{"productSku":"sku","actualDeliveryQty":2.125,"deliveryQty":99},
|
||||
{"productSku":"sku","actualDeliveryQty":3}],
|
||||
"packageList":[{"skuList":[{"actualDeliveryQty":"5.125"}]}]}}
|
||||
""");
|
||||
var order = client.detail(context("synthetic-account"), "WH-A", "C", "DO-1");
|
||||
assertThat(order.items()).hasSize(2);
|
||||
assertThat(order.items().get(0).actualQuantity()).isEqualByComparingTo("2.125");
|
||||
assertThat(order.items().get(0).quantityUnit()).isNull();
|
||||
assertThat(order.sourceCreatedTime()).isEqualTo("2025-01-01 00:00:00");
|
||||
server.verify();
|
||||
}
|
||||
@Test void inventoryPreservesUnknownNegativeAndAllQualitiesWithoutInventedArithmetic() {
|
||||
enable();
|
||||
expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":1000}", """
|
||||
{"code":"200","data":{"current":1,"size":1000,"pages":1,"total":1,"records":[
|
||||
{"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":1,
|
||||
"stkQty":120,"freezeQty":20,"useStkQty":100.125,"transitQty":-3}]}}
|
||||
""");
|
||||
var row = client.inventory(context("synthetic-account"), "WH-A", 1, 1000).records().get(0);
|
||||
assertThat(row.productQuality()).isEqualTo(1);
|
||||
assertThat(row.availableQuantity()).isEqualByComparingTo("100.125");
|
||||
assertThat(row.transitQuantity()).isEqualByComparingTo("-3");
|
||||
server.verify();
|
||||
}
|
||||
@Test void missingPageMetadataCannotBeInterpretedAsEmptySuccess() {
|
||||
enable();
|
||||
expect("/warehouses/query", "{\"current\":1,\"size\":100}",
|
||||
"{\"code\":\"200\",\"data\":{\"records\":[]}}");
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).isInstanceOf(AnalyticsClientException.class);
|
||||
server.verify();
|
||||
}
|
||||
@Test void unknownStockIsNotZeroAndDecimalPrecisionIsRetained() {
|
||||
enable();
|
||||
expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """
|
||||
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[
|
||||
{"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":0,
|
||||
"stkQty":99999999999999.123456,"freezeQty":"","useStkQty":null,"transitQty":0}]}}
|
||||
""");
|
||||
var stock = client.inventory(context("synthetic-account"), "WH-A", 1, 100).records().get(0);
|
||||
assertThat(stock.physicalQuantity()).isEqualByComparingTo("99999999999999.123456");
|
||||
assertThat(stock.frozenQuantity()).isNull();
|
||||
assertThat(stock.availableQuantity()).isNull();
|
||||
assertThat(stock.transitQuantity()).isEqualByComparingTo("0");
|
||||
server.verify();
|
||||
}
|
||||
@Test void detailIdentityMismatchIsNotTreatedAsCancellationOrZero() {
|
||||
enable();
|
||||
expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """
|
||||
{"code":"200","data":{"deliveryNo":"DO-2","customerCode":"C","whCode":"WH-A","status":99,"skuVOList":[]}}
|
||||
""");
|
||||
assertThatThrownBy(() -> client.detail(context("synthetic-account"), "WH-A", "C", "DO-1"))
|
||||
.hasMessage("DETAIL_IDENTITY_MISMATCH");
|
||||
server.verify();
|
||||
}
|
||||
@Test void scopeLeakIsRejected() {
|
||||
enable();
|
||||
expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """
|
||||
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[
|
||||
{"customerCode":"C","whCode":"WH-B","productSku":"sku","productQuality":0}]}}
|
||||
""");
|
||||
assertThatThrownBy(() -> client.inventory(context("synthetic-account"), "WH-A", 1, 100)).hasMessage("INVENTORY_WAREHOUSE_MISMATCH");
|
||||
server.verify();
|
||||
}
|
||||
@Test void errorsKeepRetryabilityWithoutRawProviderText() {
|
||||
enable();
|
||||
expect("/warehouses/query", "{\"current\":1,\"size\":100}", """
|
||||
{"code":"BIZ_503","message":"sensitive source response","data":{"retryable":true}}
|
||||
""");
|
||||
server.expect(requestTo(PREFIX + "/warehouses/query")).andRespond(withStatus(HttpStatus.UNAUTHORIZED)
|
||||
.body("sensitive token response"));
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100))
|
||||
.hasMessage("INTEGRATION_PROVIDER_FAILURE")
|
||||
.satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isTrue());
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100))
|
||||
.hasMessage("INTEGRATION_HTTP_FAILURE")
|
||||
.satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isFalse());
|
||||
server.verify();
|
||||
}
|
||||
@Test void knownErrorsPreserveSafeClassificationAndCredentialFailuresNeverRetry() {
|
||||
enable();
|
||||
String[] codes = {"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED",
|
||||
"LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE"};
|
||||
boolean[] upstreamRetryable = {true, true, false, true};
|
||||
boolean[] expectedRetryable = {false, false, false, true};
|
||||
for (int i = 0; i < codes.length; i++) {
|
||||
expect("/warehouses/query", "{\"current\":1,\"size\":100}",
|
||||
"{\"code\":\"BIZ_503\",\"message\":\"secret raw provider message\",\"data\":{\"errorCode\":\""
|
||||
+ codes[i] + "\",\"retryable\":" + upstreamRetryable[i] + "}}");
|
||||
}
|
||||
for (int i = 0; i < codes.length; i++) {
|
||||
String code = codes[i]; boolean retryable = expectedRetryable[i];
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100))
|
||||
.hasMessage(code).hasNoCause()
|
||||
.satisfies(e -> {
|
||||
assertThat(((AnalyticsClientException)e).code()).isEqualTo(code);
|
||||
assertThat(((AnalyticsClientException)e).retryable()).isEqualTo(retryable);
|
||||
});
|
||||
}
|
||||
server.verify();
|
||||
}
|
||||
@Test void unrecognizedProviderCodeCannotLeakThroughDiagnosticMessage() {
|
||||
enable();
|
||||
expect("/warehouses/query", "{\"current\":1,\"size\":100}", """
|
||||
{"code":"BIZ_400","message":"sensitive source response",
|
||||
"data":{"errorCode":"sensitive arbitrary provider code","retryable":false}}
|
||||
""");
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100))
|
||||
.hasMessage("INTEGRATION_PROVIDER_FAILURE").hasNoCause();
|
||||
server.verify();
|
||||
}
|
||||
@Test void durableDenialPreservesSampleStopAndQueueDeadline() {
|
||||
enable();
|
||||
expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"ACCOUNT_INTERVAL_WAIT\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:00:05Z\"}}}");
|
||||
expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"VALIDATION_DEADLINE\",\"sampleDone\":true,\"waitUntil\":null}}}");
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(LocalDateTime.of(2026,10,12,0,0,5)));
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue());server.verify();
|
||||
}
|
||||
@Test void allFiveEndpointsSendOnlyTheCurrentControlHeaderOverLoopbackHttp() throws Exception {
|
||||
enable();properties.setEnvironment("production");
|
||||
String control="{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}";
|
||||
var received=new java.util.concurrent.ConcurrentHashMap<String,String>();
|
||||
var calls=new java.util.concurrent.atomic.AtomicInteger();
|
||||
var wrongHeaders=new java.util.concurrent.atomic.AtomicInteger();
|
||||
var loopback=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0);
|
||||
loopback.createContext("/internal/integrations/lingxing/wms",exchange->{
|
||||
calls.incrementAndGet();String path=exchange.getRequestURI().getPath();
|
||||
String actual=exchange.getRequestHeaders().getFirst("X-Lingxing-Egress-Context");
|
||||
if(exchange.getRequestHeaders().containsKey("X-Lingxing-Egress-SelfopRequestContext"))wrongHeaders.incrementAndGet();
|
||||
boolean accepted=control.equals(actual)&&"synthetic-token".equals(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"))
|
||||
&&"POST".equals(exchange.getRequestMethod());
|
||||
if(actual!=null)received.put(path,actual);
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
String data;
|
||||
if(path.endsWith("/outbound-orders/detail")) {
|
||||
data="{\"deliveryNo\":\"D\",\"customerCode\":\"C\",\"whCode\":\"A\",\"status\":100,\"skuVOList\":[{\"productSku\":\"SKU\",\"actualDeliveryQty\":1}]}";
|
||||
} else {
|
||||
String row=path.endsWith("/warehouses/query")
|
||||
?"{\"whCode\":\"A\",\"whNameCn\":\"Synthetic\",\"warehouseType\":0,\"status\":0}"
|
||||
:path.endsWith("/product-inventory/query")
|
||||
?"{\"whCode\":\"A\",\"customerCode\":\"C\",\"productSku\":\"SKU\",\"productQuality\":0,\"stkQty\":1,\"useStkQty\":1,\"freezeQty\":0,\"transitQty\":0}"
|
||||
:"{\"whCode\":\"A\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":100}";
|
||||
data="{\"current\":1,\"size\":1,\"pages\":1,\"total\":\"1\",\"records\":["+row+"]}";
|
||||
}
|
||||
byte[] response=("{\"code\":\"200\",\"data\":"+data+"}").getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type","application/json");
|
||||
exchange.sendResponseHeaders(accepted?200:403,response.length);
|
||||
exchange.getResponseBody().write(response);exchange.close();
|
||||
});
|
||||
loopback.start();
|
||||
try {
|
||||
properties.setIntegrationBaseUrl("http://127.0.0.1:"+loopback.getAddress().getPort());
|
||||
var httpClient=new RestLingxingAnalyticsClient(RestClient.builder(),properties,egress);
|
||||
var ctx=context("synthetic-account");
|
||||
assertThat(httpClient.warehouses(ctx,1,1).records()).hasSize(1);
|
||||
assertThat(httpClient.discover(ctx,LocalDateTime.of(2026,10,1,0,0),LocalDateTime.of(2026,10,2,0,0),1,1).records()).hasSize(1);
|
||||
assertThat(httpClient.locate(ctx,"D",1,1).records()).hasSize(1);
|
||||
assertThat(httpClient.detail(ctx,"A","C","D").items()).hasSize(1);
|
||||
assertThat(httpClient.inventory(ctx,"A",1,1).records()).hasSize(1);
|
||||
assertThat(calls.get()).isEqualTo(5);assertThat(wrongHeaders.get()).isZero();
|
||||
assertThat(received).hasSize(5);assertThat(received.values()).containsOnly(control);
|
||||
verify(egress,never()).pauseUncertain(any(),any(),any());
|
||||
} finally {loopback.stop(0);}
|
||||
}
|
||||
@Test void unconfirmedCompletionResponsePersistsPauseForItsReservedAttempt() {
|
||||
enable();
|
||||
expect("/warehouses/query","{\"current\":1,\"size\":1}","""
|
||||
{"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED","retryable":false,
|
||||
"egress":{"attemptId":"synthetic-attempt","actualHttpAttempts":1,"accountPaused":true,"cooldownUntil":null}}}
|
||||
""");
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
|
||||
verify(egress).pauseUncertain(argThat(c->"synthetic-account".equals(c.sourceAccount())&&"synthetic-attempt".equals(c.attemptId())),
|
||||
eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"));server.verify();
|
||||
}
|
||||
@Test void wrongResponseAttemptCannotPersistPauseForTheCurrentReservation() {
|
||||
enable();
|
||||
expect("/warehouses/query","{\"current\":1,\"size\":1}","""
|
||||
{"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED",
|
||||
"egress":{"attemptId":"another-attempt","actualHttpAttempts":1,"accountPaused":true}}}
|
||||
""");
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_PAUSE_BINDING_INVALID");
|
||||
verify(egress,never()).pauseUncertain(any(),any(),any());server.verify();
|
||||
}
|
||||
@Test void transportResponseLossPersistsPauseWithoutAnotherHttpAttempt() {
|
||||
enable();
|
||||
server.expect(requestTo(PREFIX+"/warehouses/query")).andRespond(request->{throw new org.springframework.web.client.ResourceAccessException("synthetic reply lost");});
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_TRANSPORT_FAILURE");
|
||||
verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("INTEGRATION_TRANSPORT_FAILURE"));
|
||||
verify(egress,times(1)).reserve(any(),any(),anyInt(),any(),any(),any());server.verify();
|
||||
}
|
||||
@Test void rateAndUnconfirmedAckRemainDifferentSignalsToTheDurableAccount() {
|
||||
enable();
|
||||
String[] codes={"LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"};
|
||||
for(String code:codes){
|
||||
expect("/warehouses/query","{\"current\":1,\"size\":1}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\""+code+"\",\"retryable\":false,\"egress\":{\"attemptId\":\"synthetic-attempt\",\"actualHttpAttempts\":1,\"accountPaused\":true}}}");
|
||||
}
|
||||
for(String code:codes){
|
||||
assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage(code);
|
||||
verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq(code));
|
||||
}
|
||||
server.verify();
|
||||
}
|
||||
private void expect(String path, String request, String response) {
|
||||
server.expect(requestTo(PREFIX + path)).andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header("X-Internal-Service-Token", "synthetic-token"))
|
||||
.andExpect(header("X-Lingxing-Egress-Context", "{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}"))
|
||||
.andExpect(content().json(request, true))
|
||||
.andRespond(withSuccess(response, MediaType.APPLICATION_JSON));
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.query.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class PlatformAnalyticsControllerTest {
|
||||
private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
|
||||
@Test void rejectsCustomerEvenWithReadPermission(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var controller=new PlatformAnalyticsController(service,collection);
|
||||
var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
|
||||
assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
|
||||
verifyNoInteractions(service,collection);
|
||||
}
|
||||
@Test void writePermissionAloneDoesNotAuthorizeAnyRead(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var controller=new PlatformAnalyticsController(service,collection);
|
||||
var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
|
||||
assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null));
|
||||
verifyNoInteractions(service,collection);
|
||||
}
|
||||
@Test void allElevenReadEndpointsShareTheIndependentGuard(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var c=new PlatformAnalyticsController(service,collection);var user=platform();
|
||||
c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
|
||||
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
|
||||
verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null);
|
||||
verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null);
|
||||
verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null);
|
||||
assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
private static HandlerMethodArgumentResolver identity(){
|
||||
return new HandlerMethodArgumentResolver(){
|
||||
public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;}
|
||||
public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();}
|
||||
};
|
||||
}
|
||||
@Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary")
|
||||
.param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01")
|
||||
.param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22")
|
||||
.param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64))
|
||||
.param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture());
|
||||
assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf());
|
||||
assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current());
|
||||
}
|
||||
@Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO")
|
||||
.param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk());
|
||||
var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture());
|
||||
assertEquals("W",cap.getValue().warehouseCode());verifyNoInteractions(service);
|
||||
}
|
||||
@Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W")
|
||||
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk());
|
||||
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class);
|
||||
verify(service).inventoryCoverage(q.capture(),options.capture());assertEquals("C",q.getValue().customerCode());
|
||||
assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
|
||||
verifyNoInteractions(collection);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.inventory;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
|
||||
class InventorySnapshotAssemblerTest {
|
||||
private final LocalDateTime time = LocalDateTime.of(2026, 10, 12, 0, 0);
|
||||
private SelfopSyncScope scope() {
|
||||
var scope = new SelfopSyncScope(); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); return scope;
|
||||
}
|
||||
private SelfopSyncRun run(long count) {
|
||||
var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setId(10L); run.setSourceTotalCount(count); run.setUniqueObjectCount(count);
|
||||
run.setPageCount(1); run.setCoverageStatus("COMPLETE"); run.setFetchStartedAtUtc(time); run.setFetchFinishedAtUtc(time.plusMinutes(1));
|
||||
run.setBasePublishedRunId(9L); run.setReferenceSalesRunId(8L); return run;
|
||||
}
|
||||
private SelfopPayloads.Stock stock(String customer, String sku, Integer quality, BigDecimal available) {
|
||||
return new SelfopPayloads.Stock("demo", customer, "A", sku, quality, "Name", new BigDecimal("120"),
|
||||
available, new BigDecimal("20"), new BigDecimal("30"), null);
|
||||
}
|
||||
private SelfopSyncStage stage(SelfopPayloads.Stock stock) {
|
||||
var stage = new SelfopSyncStage(); stage.setRunId(10L); stage.setStageBatchId(10L); stage.setLastFetchRunId(10L);
|
||||
stage.setWarehouseCode(stock.warehouseCode()); stage.setObjectType("PRODUCT_STOCK"); stage.setValidationStatus("VALID");
|
||||
stage.setFirstPageNo(1); stage.setObservedAtUtc(time); stage.setPayloadJson(json(stock)); stage.setContentHash(hash(stock));
|
||||
stage.setObjectKeyHash(hash(List.of(stock.sourceAccount(), stock.customerCode(), stock.warehouseCode(), stock.skuCode(), stock.productQuality())));
|
||||
return stage;
|
||||
}
|
||||
private InventorySnapshotAssembler.Candidate build(SelfopSyncRun run, List<SelfopSyncStage> stages,
|
||||
List<SelfopInventorySnapshot> previous, List<SelfopSkuProfile> profiles, List<SelfopSkuProfile> sales) {
|
||||
return InventorySnapshotAssembler.assemble(scope(), run, stages, previous, profiles, sales, "PCS");
|
||||
}
|
||||
@Test void quantitiesRemainIndependentAndAllQualitiesKeepTheirIdentity() {
|
||||
var rows = List.of(stage(stock("c", "sku", 0, new BigDecimal("100"))),
|
||||
stage(stock("c", "sku", 1, new BigDecimal("8"))), stage(stock("c", "sku2", 0, BigDecimal.ZERO)));
|
||||
var candidate = build(run(3), rows, List.of(), List.of(), List.of());
|
||||
assertEquals(3, candidate.snapshots().size()); assertEquals(2, candidate.profiles().size());
|
||||
var regular = candidate.snapshots().get(0);
|
||||
assertEquals(new BigDecimal("100"), regular.getAvailableQuantity());
|
||||
assertEquals(new BigDecimal("30"), regular.getTransitQuantity());
|
||||
assertEquals("VALID", regular.getQualityStatus());
|
||||
assertEquals(BigDecimal.ZERO, candidate.snapshots().get(2).getAvailableQuantity());
|
||||
}
|
||||
@Test void missingAndNegativeQuantitiesNeverTurnIntoZero() {
|
||||
var candidate = build(run(2), List.of(stage(stock("c", "unknown", 0, null)),
|
||||
stage(stock("c", "negative", 0, BigDecimal.ONE.negate()))), List.of(), List.of(), List.of());
|
||||
assertEquals("UNKNOWN", candidate.snapshots().get(0).getQualityStatus());
|
||||
assertNull(candidate.snapshots().get(0).getAvailableQuantity());
|
||||
assertEquals("INVALID", candidate.snapshots().get(1).getQualityStatus());
|
||||
assertEquals(BigDecimal.ONE.negate(), candidate.snapshots().get(1).getAvailableQuantity());
|
||||
}
|
||||
@Test void unrepresentableDecimalsArePreservedInStageAndCannotBeRoundedInSnapshot() {
|
||||
var source = stage(stock("c", "precise", 0, new BigDecimal("0.1234567")));
|
||||
var candidate = build(run(1), List.of(source), List.of(), List.of(), List.of());
|
||||
assertEquals("QUANTITY_PRECISION", candidate.snapshots().get(0).getQualityReason());
|
||||
assertNull(candidate.snapshots().get(0).getAvailableQuantity());
|
||||
assertEquals(new BigDecimal("0.1234567"), read(source.getPayloadJson(), SelfopPayloads.Stock.class).availableQuantity());
|
||||
}
|
||||
@Test void total5741Unique5740RejectsEvenIfRowCountMatchesSourceTotal() {
|
||||
var run = run(5741); run.setUniqueObjectCount(5740L); run.setPageCount(6);
|
||||
var rows = new ArrayList<SelfopSyncStage>();
|
||||
for (int i = 0; i < 5741; i++) rows.add(stage(stock("c", "sku" + (i % 5740), 0, BigDecimal.ONE)));
|
||||
assertThrows(IllegalStateException.class, () -> build(run, rows, List.of(), List.of(), List.of()));
|
||||
}
|
||||
@Test void duplicateBusinessIdentityRejectedEvenIfDistinctStageHashesClaimComplete() {
|
||||
var first = stage(stock("c", "sku", 0, BigDecimal.ONE));
|
||||
var second = stage(stock("c", "sku", 0, BigDecimal.TEN)); second.setObjectKeyHash("different");
|
||||
assertThrows(IllegalStateException.class, () -> build(run(2), List.of(first, second), List.of(), List.of(), List.of()));
|
||||
}
|
||||
@Test void newAttemptCannotBorrowOldAttemptPages() {
|
||||
var stage = stage(stock("c", "sku", 0, BigDecimal.ONE)); stage.setStageBatchId(9L);
|
||||
assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage), List.of(), List.of(), List.of()));
|
||||
}
|
||||
@Test void warehouseLeakOrTamperedContentRejected() {
|
||||
var stock = stock("c", "sku", 0, BigDecimal.ONE);
|
||||
var wrong = new SelfopPayloads.Stock(stock.sourceAccount(), stock.customerCode(), "B", stock.skuCode(), 0,
|
||||
null, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage(wrong)), List.of(), List.of(), List.of()));
|
||||
var tampered = stage(stock); tampered.setContentHash("bad");
|
||||
assertThrows(IllegalStateException.class, () -> build(run(1), List.of(tampered), List.of(), List.of(), List.of()));
|
||||
}
|
||||
@Test void fixedReferencesGenerateUnknownAndSalesCannotCreateInventoryEvidence() {
|
||||
var previous = new SelfopInventorySnapshot(); previous.setSnapshotRunId(9L); previous.setSourceAccount("demo");
|
||||
previous.setWarehouseCode("A"); previous.setCustomerCode("c"); previous.setSkuCode("old"); previous.setProductQuality(1);
|
||||
previous.setAvailableQuantity(BigDecimal.TEN);
|
||||
var old = profile(9L, "old"); old.setEvidenceType("INVENTORY_OBSERVATION"); old.setFirstSeenAtUtc(time.minusDays(3));
|
||||
old.setEarliestEvidenceAtUtc(time.minusDays(3)); old.setEvidenceRunId(1L); old.setLastSeenRunId(9L);
|
||||
var sales = profile(8L, "salesOnly"); sales.setEvidenceType("VALID_OUTBOUND"); sales.setEarliestEvidenceAtUtc(time.minusDays(100));
|
||||
var candidate = build(run(0), List.of(), List.of(previous), List.of(old), List.of(sales));
|
||||
assertEquals(2, candidate.snapshots().size());
|
||||
assertTrue(candidate.snapshots().stream().allMatch(s -> "UNKNOWN".equals(s.getQualityStatus()) && s.getAvailableQuantity() == null));
|
||||
var inherited = candidate.profiles().stream().filter(p -> "old".equals(p.getSkuCode())).findFirst().orElseThrow();
|
||||
assertEquals(time.minusDays(3), inherited.getFirstSeenAtUtc()); assertEquals(9L, inherited.getLastSeenRunId());
|
||||
var placeholder = candidate.profiles().stream().filter(p -> "salesOnly".equals(p.getSkuCode())).findFirst().orElseThrow();
|
||||
assertEquals("NONE", placeholder.getEvidenceType()); assertNull(placeholder.getEarliestEvidenceAtUtc());
|
||||
assertNull(placeholder.getEvidenceRunId()); assertNull(placeholder.getQuantityUnit());
|
||||
assertEquals(BigDecimal.TEN, previous.getAvailableQuantity()); assertEquals(9L, old.getProfileRunId());
|
||||
}
|
||||
@Test void inventoryFirstSeenIsInheritedOnlyFromInventoryNotSales() {
|
||||
var old = profile(9L, "sku"); old.setEvidenceType("NONE"); old.setFirstSeenAtUtc(time.minusDays(90));
|
||||
var candidate = build(run(1), List.of(stage(stock("c", "sku", 0, BigDecimal.ONE))), List.of(), List.of(old), List.of());
|
||||
assertEquals(time, candidate.profiles().get(0).getFirstSeenAtUtc());
|
||||
}
|
||||
private SelfopSkuProfile profile(long runId, String sku) {
|
||||
var profile = new SelfopSkuProfile(); profile.setProfileRunId(runId); profile.setSourceAccount("demo");
|
||||
profile.setWarehouseCode("A"); profile.setCustomerCode("c"); profile.setSkuCode(sku); return profile;
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.inventory;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class InventorySnapshotServiceTest {
|
||||
private final SelfopCoordinationMapper coordination = mock(SelfopCoordinationMapper.class);
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class);
|
||||
private final SelfopInventorySnapshotMapper snapshots = mock(SelfopInventorySnapshotMapper.class);
|
||||
private final SelfopSkuProfileMapper profiles = mock(SelfopSkuProfileMapper.class);
|
||||
private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties();
|
||||
private final InventorySnapshotService service = new InventorySnapshotService(coordination, scopes, runs, tasks, stages, snapshots, profiles, properties);
|
||||
private SelfopSyncScope scope;
|
||||
private SelfopSyncRun run;
|
||||
private SelfopSyncTask task;
|
||||
@BeforeEach void prepare() {
|
||||
scope = new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A");
|
||||
scope.setDataFamily("INVENTORY"); scope.setScopeHash("hash"); scope.setLeaseOwner("owner");
|
||||
scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).plusHours(1)); scope.setFenceToken(4L);
|
||||
scope.setRebuildRequired(1); scope.setFactRevision(9L);
|
||||
run = new SelfopSyncRun(); run.setId(10L); run.setTaskId(20L); run.setScopeId(1L);
|
||||
run.setSourceAccount("demo"); run.setWarehouseCode("A"); run.setRunRole("WAREHOUSE_SYNC");
|
||||
run.setExecutionPurpose("BUSINESS"); run.setScopeHash("hash"); run.setFenceToken(4L); run.setStatus("READY"); run.setCoverageStatus("COMPLETE");
|
||||
run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}");
|
||||
run.setSourceTotalCount(0L); run.setUniqueObjectCount(0L); run.setPageCount(1);
|
||||
run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1));
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setId(20L); task.setScopeId(1L); task.setScopeHash("hash");
|
||||
task.setSourceAccount("demo"); task.setWarehouseCode("A"); task.setApplyState("NOT_APPLICABLE");
|
||||
when(coordination.lockScope(1L)).thenReturn(scope); when(coordination.lockRun(10L)).thenReturn(run);
|
||||
when(coordination.lockTask(20L)).thenReturn(task);
|
||||
when(stages.selectList(any())).thenReturn(List.of()); when(snapshots.selectList(any())).thenReturn(List.of());
|
||||
when(profiles.selectList(any())).thenReturn(List.of());
|
||||
properties.setQuantityUnit("PCS");
|
||||
}
|
||||
@Test void inventoryPublicationDoesNotConsultSalesBarrierOrModifyOutboundRevision() {
|
||||
when(coordination.barriers("demo", "A")).thenReturn(1L);
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals(10L, scope.getPublishedRunId()); assertEquals("PUBLISHED", run.getStatus());
|
||||
assertEquals(1, scope.getRebuildRequired()); assertEquals(9L, scope.getFactRevision());
|
||||
verify(coordination, never()).barriers(anyString(), anyString());
|
||||
verify(tasks).updateById(argThat((SelfopSyncTask t) -> "SUCCEEDED".equals(t.getStatus()) && Long.valueOf(10L).equals(t.getResultPublishedRunId())));
|
||||
assertEquals(10L, task.getResultPublishedRunId());
|
||||
}
|
||||
@Test void oldFenceOrExpiredLeaseCannotMovePointer() {
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 3L));
|
||||
scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).minusSeconds(1));
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
@Test void newerInventoryPublicationCannotBeOverwrittenByOldCandidate() {
|
||||
scope.setPublishedRunId(11L);
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
@Test void unknownUnitFailsClosedAndUnreadyCandidateCannotPublish() {
|
||||
run.setScopeSnapshotJson("{\"quantityUnit\":null}");
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setStatus("FETCHING");
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
@Test void readyAndPublishedRetryNeverChangesImmutableCandidateRows() {
|
||||
service.build(1L, 10L, "owner", 4L);
|
||||
verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class));
|
||||
verify(snapshots, never()).insert(anyCollection(), anyInt());
|
||||
verify(profiles, never()).insert(any(SelfopSkuProfile.class));
|
||||
verify(profiles, never()).insert(anyCollection(), anyInt());
|
||||
service.publish(1L, 10L, "owner", 4L); service.publish(1L, 10L, "owner", 4L);
|
||||
verify(scopes, times(1)).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
@Test void malformedCompleteClaimFailsBeforeAnyCandidateIsWritten() {
|
||||
run.setStatus("VALIDATED"); run.setSourceTotalCount(5741L); run.setUniqueObjectCount(5740L);
|
||||
assertThrows(IllegalStateException.class, () -> service.build(1L, 10L, "owner", 4L));
|
||||
verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class));
|
||||
verify(snapshots, never()).insert(anyCollection(), anyInt());
|
||||
verify(runs, never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
@Test void emptyCompleteRunCanBuildAndPublishWithoutInventingSkuEvidence() {
|
||||
run.setStatus("VALIDATED");
|
||||
service.build(1L, 10L, "owner", 4L);
|
||||
assertEquals("READY", run.getStatus()); verify(runs).updateById(run);
|
||||
verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class));
|
||||
verify(snapshots, never()).insert(anyCollection(), anyInt());
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals("PUBLISHED", run.getStatus());
|
||||
}
|
||||
@Test void missingProfileRefusesPointerSwitch() {
|
||||
var row = new SelfopInventorySnapshot(); row.setSnapshotRunId(10L); row.setSourceAccount("demo");
|
||||
row.setWarehouseCode("A"); row.setCustomerCode("c"); row.setSkuCode("sku");
|
||||
when(snapshots.selectList(any())).thenReturn(List.of(row));
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
@Test void validationPurposeCannotBuildOrPublishEvenWhenCompleteAndFenceValid() {
|
||||
run.setExecutionPurpose("VALIDATION");run.setStatus("VALIDATED");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.build(1L,10L,"owner",4L)).getMessage());
|
||||
run.setStatus("READY");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage());
|
||||
verify(snapshots, never()).insert(anyCollection(),anyInt());verify(profiles, never()).insert(anyCollection(),anyInt());verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId());
|
||||
}
|
||||
@Test void validationTaskCannotPublishBusinessRunOrAcquireResultLink() {
|
||||
task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(20L);
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage());
|
||||
verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId());
|
||||
}
|
||||
@Test void candidateBulkInsertHasIdsAndCapsConfiguredBatchSizeAt500() {
|
||||
run.setStatus("VALIDATED");run.setSourceTotalCount(1L);run.setUniqueObjectCount(1L);properties.setPersistenceBatchSize(1000);
|
||||
var payload=new SelfopPayloads.Stock("demo","c","A","sku",0,"Name",java.math.BigDecimal.TEN,java.math.BigDecimal.ONE,java.math.BigDecimal.ZERO,java.math.BigDecimal.ZERO,null);
|
||||
var stage=new SelfopSyncStage();stage.setId(100L);stage.setRunId(10L);stage.setStageBatchId(10L);stage.setLastFetchRunId(10L);stage.setObjectType("PRODUCT_STOCK");stage.setWarehouseCode("A");stage.setValidationStatus("VALID");stage.setFirstPageNo(1);stage.setObservedAtUtc(run.getFetchStartedAtUtc());stage.setPayloadJson(SelfopSupport.json(payload));stage.setContentHash(SelfopSupport.hash(payload));stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","c","A","sku",0)));
|
||||
when(stages.selectList(any())).thenReturn(List.of(stage));service.build(1L,10L,"owner",4L);
|
||||
verify(snapshots).insert(argThat((java.util.Collection<SelfopInventorySnapshot> rows) -> rows.size()==1 && rows.iterator().next().getId()!=null && Long.valueOf(10L).equals(rows.iterator().next().getSnapshotRunId())),eq(500));
|
||||
verify(profiles).insert(argThat((java.util.Collection<SelfopSkuProfile> rows) -> rows.size()==1 && rows.iterator().next().getId()!=null),eq(500));
|
||||
verify(snapshots,never()).insert(any(SelfopInventorySnapshot.class));verify(profiles,never()).insert(any(SelfopSkuProfile.class));assertEquals("READY",run.getStatus());
|
||||
}
|
||||
@Test void publishedReferenceMustAlsoBeBusinessPurpose() {
|
||||
run.setStatus("VALIDATED");run.setBasePublishedRunId(9L);scope.setPublishedRunId(9L);
|
||||
var ref=new SelfopSyncRun();ref.setId(9L);ref.setExecutionPurpose("VALIDATION");ref.setStatus("PUBLISHED");ref.setRunRole("WAREHOUSE_SYNC");ref.setSourceAccount("demo");ref.setWarehouseCode("A");when(runs.selectById(9L)).thenReturn(ref);
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.build(1L,10L,"owner",4L)).getMessage());
|
||||
verify(snapshots,never()).insert(anyCollection(),anyInt());
|
||||
}
|
||||
@Test void liveUnitConfigCannotOverrideFixedRunConfiguration() {
|
||||
properties.setQuantityUnit(null);
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals("PUBLISHED", run.getStatus());
|
||||
prepare(); properties.setQuantityUnit("PCS"); run.setScopeSnapshotJson("{\"quantityUnit\":null}");
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
}
|
||||
|
||||
private void scheduledObservation() {
|
||||
task.setTaskRole("WAREHOUSE_SYNC"); task.setTaskType("SCHEDULED");
|
||||
task.setPlannedAtUtc(run.getFetchStartedAtUtc());
|
||||
}
|
||||
|
||||
@Test void firstScheduledSuccessSelectsBeijingDayInPublicationTransaction() {
|
||||
scheduledObservation();
|
||||
// Beijing midnight is the previous UTC date; host timezone is irrelevant.
|
||||
task.setPlannedAtUtc(LocalDateTime.of(2026, 10, 11, 16, 0));
|
||||
run.setFetchStartedAtUtc(task.getPlannedAtUtc().plusMinutes(1));
|
||||
run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1));
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate());
|
||||
assertEquals("DAILY_SCHEDULED_FIRST_V1", run.getInventorySampleRuleVersion());
|
||||
var order = inOrder(coordination, runs, scopes, tasks);
|
||||
order.verify(coordination).lockScope(1L);
|
||||
order.verify(runs).dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12));
|
||||
order.verify(scopes).updateById(scope);
|
||||
order.verify(runs).updateById(run);
|
||||
order.verify(tasks).updateById(task);
|
||||
}
|
||||
|
||||
@Test void laterSameDaySuccessPublishesLatestWithoutReplacingSelectedRepresentative() {
|
||||
scheduledObservation();
|
||||
var selected = new SelfopSyncRun(); selected.setId(9L);
|
||||
selected.setStatus("PUBLISHED"); selected.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1");
|
||||
selected.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12));
|
||||
when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(selected);
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals(10L, scope.getPublishedRunId());
|
||||
assertNull(run.getInventoryDailySampleDate()); assertNull(run.getInventorySampleRuleVersion());
|
||||
assertEquals(LocalDate.of(2026, 10, 12), selected.getInventoryDailySampleDate());
|
||||
}
|
||||
|
||||
@Test void failedAttemptDoesNotOccupyDayAndSuccessfulRetryMaySelectIt() {
|
||||
scheduledObservation(); run.setStatus("FAILED");
|
||||
assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L));
|
||||
assertNull(run.getInventoryDailySampleDate());
|
||||
verify(runs, never()).dailyInventorySample(anyString(), anyString(), any());
|
||||
// A new attempt uses a new run; the failed attempt remains immutable.
|
||||
var failed = run;
|
||||
run = new SelfopSyncRun(); org.springframework.beans.BeanUtils.copyProperties(failed, run);
|
||||
run.setId(11L); run.setStatus("READY"); run.setAttemptNo(2);
|
||||
when(coordination.lockRun(11L)).thenReturn(run);
|
||||
service.publish(1L, 11L, "owner", 4L);
|
||||
assertEquals("FAILED", failed.getStatus()); assertNull(failed.getInventoryDailySampleDate());
|
||||
assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate());
|
||||
}
|
||||
|
||||
@Test void crossMidnightAndOldSlotCatchupRemainCurrentInventoryButNeverDailySamples() {
|
||||
scheduledObservation();
|
||||
run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 15, 59));
|
||||
run.setFetchFinishedAtUtc(LocalDateTime.of(2026, 10, 12, 16, 1));
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate());
|
||||
prepare(); scheduledObservation();
|
||||
task.setPlannedAtUtc(task.getPlannedAtUtc().minusDays(1));
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate());
|
||||
verify(runs, never()).dailyInventorySample(anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test void manualAndIncompleteEvidenceCannotAcquireDailyMarker() {
|
||||
scheduledObservation(); task.setTaskType("MANUAL");
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertNull(run.getInventoryDailySampleDate());
|
||||
prepare(); scheduledObservation(); run.setFetchFinishedAtUtc(null);
|
||||
assertNull(InventorySnapshotService.eligibleSampleDate(task, run));
|
||||
run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc()); run.setUniqueObjectCount(1L);
|
||||
assertNull(InventorySnapshotService.eligibleSampleDate(task, run));
|
||||
run.setUniqueObjectCount(0L); run.setExecutionPurpose("VALIDATION");
|
||||
assertNull(InventorySnapshotService.eligibleSampleDate(task, run));
|
||||
}
|
||||
|
||||
@Test void publishedRecoveryRetainsMarkerAndNeverRepublishesEvenWhenSuperseded() {
|
||||
scheduledObservation(); service.publish(1L, 10L, "owner", 4L);
|
||||
when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(run);
|
||||
scope.setPublishedRunId(11L); // Another successful run has become current.
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals(11L, scope.getPublishedRunId());
|
||||
assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate());
|
||||
verify(scopes, times(1)).updateById(scope); verify(runs, times(1)).updateById(run);
|
||||
}
|
||||
|
||||
@Test void unmarkedPublishedRecoveryCannotRetroactivelyElectOldRun() {
|
||||
scheduledObservation();
|
||||
var previous = new SelfopSyncRun(); previous.setId(9L);
|
||||
previous.setStatus("PUBLISHED"); previous.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1");
|
||||
when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(previous);
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
clearInvocations(runs);
|
||||
when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(null);
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertNull(run.getInventoryDailySampleDate());
|
||||
verify(runs, never()).dailyInventorySample(anyString(), anyString(), any());
|
||||
verify(runs, never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
|
||||
@Test void sameQuantityNextDayStillRecordsNewObservationAndRejectsPremarkedCandidate() {
|
||||
scheduledObservation();
|
||||
task.setPlannedAtUtc(task.getPlannedAtUtc().plusDays(1));
|
||||
run.setFetchStartedAtUtc(run.getFetchStartedAtUtc().plusDays(1));
|
||||
run.setFetchFinishedAtUtc(run.getFetchFinishedAtUtc().plusDays(1));
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
assertEquals(LocalDate.of(2026, 10, 13), run.getInventoryDailySampleDate());
|
||||
prepare(); scheduledObservation(); run.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12));
|
||||
assertEquals("SELFOP_INVENTORY_UNPUBLISHED_SAMPLE", assertThrows(IllegalStateException.class,
|
||||
() -> service.publish(1L, 10L, "owner", 4L)).getMessage());
|
||||
}
|
||||
|
||||
@Test void recoveryRejectsPartiallyPersistedSampleEvidence() {
|
||||
service.publish(1L, 10L, "owner", 4L);
|
||||
run.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1");
|
||||
assertEquals("SELFOP_INVENTORY_SAMPLE_INVALID", assertThrows(IllegalStateException.class,
|
||||
() -> service.publish(1L, 10L, "owner", 4L)).getMessage());
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.outbound;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */
|
||||
public class OutboundAggregateMapperTest {
|
||||
JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
data=new JdbcDataSource(); data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||
sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)");
|
||||
sql("CREATE TABLE ll_selfop_outbound_item (id BIGINT PRIMARY KEY,order_id BIGINT,sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),actual_quantity DECIMAL(20,6))");
|
||||
sql("CREATE TABLE ll_selfop_sync_stage (id BIGINT PRIMARY KEY,run_id BIGINT,warehouse_code VARCHAR(64),object_type VARCHAR(32),validation_status VARCHAR(24),observation_sequence BIGINT,observed_at_utc TIMESTAMP)");
|
||||
sql("CREATE TABLE ll_selfop_sync_run (id BIGINT PRIMARY KEY,source_account VARCHAR(64),task_id BIGINT,execution_purpose VARCHAR(24))");
|
||||
sql("CREATE TABLE ll_selfop_sync_task (id BIGINT PRIMARY KEY,execution_purpose VARCHAR(24),validation_root_task_id BIGINT,scope_id BIGINT,scope_hash VARCHAR(64),apply_state VARCHAR(24),query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,routing_manifest_json VARCHAR)");
|
||||
sql("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonExtract'");
|
||||
sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'");
|
||||
sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
|
||||
sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)");
|
||||
Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data));
|
||||
config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class);
|
||||
session=new SqlSessionFactoryBuilder().build(config).openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class);
|
||||
}
|
||||
@AfterEach void close() { if(session!=null) session.close(); }
|
||||
void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}}
|
||||
void order(long id,String account,String customer,String warehouse,String sku,int eligible,String time,int quantity) throws Exception {
|
||||
sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,validation_root_task_id) VALUES ("+id+",'BUSINESS',NULL)");
|
||||
sql("INSERT INTO ll_selfop_sync_run VALUES ("+id+",'"+account+"',"+id+",'BUSINESS')");
|
||||
sql("INSERT INTO ll_selfop_sync_stage VALUES ("+id+","+id+",'"+warehouse+"','OUTBOUND_ORDER','VALID',"+id+",'2026-10-12 01:00:00')");
|
||||
sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")");
|
||||
sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")");
|
||||
}
|
||||
@Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception {
|
||||
order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2);
|
||||
order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3);
|
||||
order(3,"demo","C2","A","SKU",1,"2026-10-01 00:00:00",7);
|
||||
order(4,"demo","C1","A","OLD",0,"2026-09-01 00:00:00",99);
|
||||
order(5,"demo","C1","B","MOVED",1,"2026-09-01 00:00:00",99);
|
||||
assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS"));
|
||||
var daily=mapper.dailyBatch("demo","A",null,null,null); assertEquals(2,daily.size());
|
||||
assertEquals(0,new java.math.BigDecimal("5").compareTo(daily.get(0).getOutboundQuantity())); assertEquals(2L,daily.get(0).getOutboundOrderCount());
|
||||
var profiles=mapper.profileBatch("demo","A",null,null); assertEquals(2,profiles.size());
|
||||
assertEquals(LocalDateTime.of(2026,10,1,0,0),profiles.get(0).getEarliestEvidenceAtUtc()); assertEquals(1L,profiles.get(0).getEvidenceRunId());
|
||||
sql("UPDATE ll_selfop_outbound_order SET sales_eligible=0 WHERE id=1");
|
||||
var corrected=mapper.profileBatch("demo","A",null,null); assertEquals(LocalDateTime.of(2026,10,2,0,0),corrected.get(0).getEarliestEvidenceAtUtc()); assertEquals(2L,corrected.get(0).getEvidenceRunId());
|
||||
assertEquals(1,mapper.profileBatch("demo","A","C1","SKU").size());
|
||||
}
|
||||
@Test void validationEvidenceAndAccountSequenceCollisionAreFailClosed() throws Exception {
|
||||
order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2);
|
||||
order(2,"other","C1","A","SKU",1,"2026-10-01 00:00:00",9);
|
||||
sql("UPDATE ll_selfop_sync_stage SET observation_sequence=1 WHERE id=2");
|
||||
assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS"));
|
||||
sql("UPDATE ll_selfop_sync_run SET execution_purpose='VALIDATION' WHERE id=1");
|
||||
assertEquals(1,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS"));
|
||||
assertTrue(mapper.profileBatch("demo","A",null,null).isEmpty());
|
||||
}
|
||||
@Test void databaseResultPagesAndMultiRowInsertsAreBounded() throws Exception {
|
||||
// Insert synthetic local orders without any provider I/O.
|
||||
try(Connection c=data.getConnection();PreparedStatement statement=c.prepareStatement("INSERT INTO ll_selfop_outbound_order VALUES (?,'demo','C1','A',1,'2026-10-01 00:00:00','2026-10-01','Asia/Shanghai','Asia/Shanghai',?)");
|
||||
PreparedStatement item=c.prepareStatement("INSERT INTO ll_selfop_outbound_item VALUES (?, ?, ?, 'item','PCS',1)")) {
|
||||
for(long id=1;id<=501;id++) {statement.setLong(1,id); statement.setLong(2,id);statement.addBatch();item.setLong(1,id); item.setLong(2,id);item.setString(3,String.format("SKU%04d",id));item.addBatch();}
|
||||
statement.executeBatch(); item.executeBatch();
|
||||
}
|
||||
var first=mapper.dailyBatch("demo","A",null,null,null); assertEquals(500,first.size());
|
||||
var last=first.get(499); var second=mapper.dailyBatch("demo","A",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate());assertEquals(1,second.size());
|
||||
long id=1; for(var row:first) {row.setId(id++);row.setSalesRunId(900L);row.setSourceAccount("demo");row.setWarehouseCode("A");row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(row.getCreatedAt());}
|
||||
assertEquals(500,mapper.insertDaily(first));
|
||||
try(Connection c=data.getConnection();ResultSet result=c.createStatement().executeQuery("SELECT COUNT(*) FROM ll_selfop_sku_daily_sales")){assertTrue(result.next());assertEquals(500,result.getLong(1));}
|
||||
}
|
||||
public static String jsonExtract(String value,String path) throws Exception {
|
||||
if(value==null)return null;
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).path("directoryComplete").asText();
|
||||
}
|
||||
public static String jsonUnquote(String value) {return value;}
|
||||
@Test void databaseCoverageJoinsOverlapsAndStopsAtFirstUnprovedGap() throws Exception {
|
||||
sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,scope_id,scope_hash,apply_state,query_from_utc,query_until_utc,routing_manifest_json) VALUES (1,'BUSINESS',1,'h','APPLIED','2026-10-01 00:00:00','2026-10-03 00:00:00','{\"directoryComplete\":true}'),(2,'BUSINESS',1,'h','APPLIED','2026-10-02 00:00:00','2026-10-04 00:00:00','{\"directoryComplete\":true}'),(3,'BUSINESS',1,'h','APPLIED','2026-10-05 00:00:00','2026-10-06 00:00:00','{\"directoryComplete\":true}'),(4,'VALIDATION',1,'h','APPLIED','2026-10-04 00:00:00','2026-10-05 00:00:00','{\"directoryComplete\":true}')");
|
||||
var coverage=mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null);
|
||||
assertEquals(LocalDateTime.of(2026,10,1,0,0),coverage.getCoveredFromUtc());
|
||||
assertEquals(LocalDateTime.of(2026,10,4,0,0),coverage.getCoveredUntilUtc());
|
||||
sql("UPDATE ll_selfop_sync_task SET execution_purpose='BUSINESS' WHERE id=4");
|
||||
assertEquals(LocalDateTime.of(2026,10,6,0,0),mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null).getCoveredUntilUtc());
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.outbound;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OutboundCandidateBuilderTest {
|
||||
final OutboundLockMapper locks=mock(OutboundLockMapper.class);
|
||||
final OutboundFactService facts=mock(OutboundFactService.class);
|
||||
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
|
||||
final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
|
||||
final SelfopSkuDailySalesMapper daily=mock(SelfopSkuDailySalesMapper.class);
|
||||
final SelfopSkuProfileMapper profiles=mock(SelfopSkuProfileMapper.class);
|
||||
final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class);
|
||||
final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class);
|
||||
final OutboundCandidateTransaction transaction=new OutboundCandidateTransaction(locks,facts,runs,tasks,scopes,daily,profiles,aggregates,new SelfopAnalyticsProperties());
|
||||
final OutboundCandidateBuilder builder=new OutboundCandidateBuilder(transaction);
|
||||
SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task;
|
||||
@BeforeEach void setup() {
|
||||
scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); scope.setDataFamily("OUTBOUND");
|
||||
scope.setScopeHash("scope"); scope.setLeaseOwner("worker"); scope.setFenceToken(2L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1));
|
||||
scope.setFactRevision(5L); scope.setSourceTimezone("Asia/Shanghai"); scope.setBusinessTimezone("Asia/Shanghai"); scope.setScopeJson("{\"quantityUnit\":\"PCS\"}");
|
||||
scope.setInitialFromUtc(LocalDateTime.of(2026,9,12,0,0));
|
||||
run=new SelfopSyncRun(); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash("scope");
|
||||
run.setExecutionPurpose("BUSINESS"); run.setScopeSnapshotJson(scope.getScopeJson());
|
||||
run.setAttemptNo(1);
|
||||
run.setFenceToken(2L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("FACTS_APPLIED");
|
||||
task=new SelfopSyncTask(); task.setId(20L); task.setExecutionPurpose("BUSINESS"); task.setApplyState("APPLIED");
|
||||
task.setScopeId(1L);task.setSourceAccount("demo");task.setWarehouseCode("A");task.setScopeHash("scope");
|
||||
task.setQueryFromUtc(scope.getInitialFromUtc()); task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,0,0));
|
||||
SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); task.setRoutingManifestJson(SelfopSupport.json(manifest));
|
||||
when(locks.scope(1L)).thenReturn(scope); when(locks.run(10L)).thenReturn(run); when(locks.task(20L)).thenReturn(task);
|
||||
SelfopSyncRun coverage=new SelfopSyncRun();coverage.setCoveredFromUtc(task.getQueryFromUtc());coverage.setCoveredUntilUtc(task.getQueryUntilUtc());
|
||||
when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(coverage);
|
||||
}
|
||||
@Test void fullRebuildWithNoCurrentFactsRetractsAllRemovedEvidence() {
|
||||
builder.build(1L,10L,"worker",2);
|
||||
verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any());
|
||||
verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope);
|
||||
assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus());
|
||||
assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom());
|
||||
assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId());
|
||||
}
|
||||
@Test void pagesAreKeysetBoundedAndWrittenInBulk() {
|
||||
List<SelfopSkuDailySales> rows=new ArrayList<>();
|
||||
for(int n=0;n<500;n++) { SelfopSkuDailySales row=new SelfopSkuDailySales(); row.setCustomerCode("c"); row.setSkuCode(String.format("%03d",n)); row.setBusinessDate(LocalDate.of(2026,10,1)); rows.add(row); }
|
||||
SelfopSkuDailySales tail=new SelfopSkuDailySales(); tail.setCustomerCode("c"); tail.setSkuCode("500"); tail.setBusinessDate(LocalDate.of(2026,10,1));
|
||||
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows);
|
||||
when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail));
|
||||
when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of());
|
||||
when(daily.selectCount(any())).thenReturn(501L);
|
||||
builder.build(1L,10L,"worker",2);
|
||||
verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail));
|
||||
verify(daily,never()).insert(any(SelfopSkuDailySales.class));
|
||||
assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null));
|
||||
}
|
||||
@Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() {
|
||||
scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L);
|
||||
SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope");
|
||||
published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
|
||||
published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
|
||||
published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc());
|
||||
published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published);
|
||||
assertNull(builder.reusable(1L,10L,"worker",2));
|
||||
published.setCoveredUntilUtc(task.getQueryUntilUtc()); assertEquals(9L,builder.reusable(1L,10L,"worker",2));
|
||||
verifyNoInteractions(daily,profiles);
|
||||
}
|
||||
@Test void validationBuildAndInvalidEvidenceFailBeforeDeletingCandidate() {
|
||||
run.setExecutionPurpose("VALIDATION"); assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2));
|
||||
run.setExecutionPurpose("BUSINESS"); when(aggregates.invalidFacts(anyString(),anyString(),anyString(),anyString(),anyString())).thenReturn(1L);
|
||||
assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2)); verifyNoInteractions(daily,profiles);
|
||||
}
|
||||
@Test void partialBatchCommitsCheckpointAndRevisionChangePreventsReady() {
|
||||
transaction.begin(1L,10L,"worker",2);
|
||||
SelfopSkuDailySales row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("s");row.setBusinessDate(LocalDate.of(2026,10,1));
|
||||
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row));
|
||||
var checkpoint=transaction.batch(1L,10L,"worker",2);
|
||||
assertEquals(1,checkpoint.dailyRows());assertEquals("BUILDING",run.getStatus());assertNotNull(task.getProgressUpdatedAtUtc());assertNotNull(run.getHeartbeatAtUtc());
|
||||
assertTrue(task.getProgressJson().contains("candidateBuild"));assertNull(scope.getPublishedRunId());
|
||||
Map<?,?> progress=SelfopSupport.read(task.getProgressJson(),Map.class);
|
||||
assertEquals("BUILD",progress.get("phase"));assertEquals("BATCH",progress.get("unit"));assertEquals(1L,((Number)progress.get("completed")).longValue());
|
||||
assertEquals("ATTEMPT_ONLY",progress.get("countBasis"));assertFalse(progress.containsKey("processedCount"));
|
||||
assertDoesNotThrow(()->Instant.parse((String)progress.get("stageStartedAt")));assertTrue(progress.get("stages") instanceof List<?>);
|
||||
scope.setFactRevision(6L);assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2));
|
||||
assertEquals("BUILDING",run.getStatus());verify(aggregates,times(1)).insertDaily(any());
|
||||
}
|
||||
@Test void crashResumesSameRunCursorWithoutDeletingCommittedRows() {
|
||||
var started=transaction.begin(1L,10L,"worker",2);clearInvocations(daily,profiles);
|
||||
assertEquals(started,transaction.begin(1L,10L,"worker",2));verifyNoInteractions(daily,profiles);
|
||||
scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
|
||||
assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2));
|
||||
}
|
||||
@Test void observationChangeWithoutRevisionStillAbortsCandidate() {
|
||||
transaction.begin(1L,10L,"worker",2);
|
||||
when(aggregates.observationsChangedSince(eq("demo"),eq("A"),any())).thenReturn(1L);
|
||||
assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2));
|
||||
verify(aggregates,never()).insertDaily(any());assertEquals("BUILDING",run.getStatus());
|
||||
}
|
||||
@Test void facadeHasNoLongTransactionAndWorkerMethodsDeclareOwnTransactions() throws Exception {
|
||||
assertNull(OutboundCandidateBuilder.class.getMethod("build",Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class));
|
||||
for(String name:List.of("begin","batch","finish"))assertNotNull(OutboundCandidateTransaction.class.getMethod(name,Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class));
|
||||
}
|
||||
@Test void onlyRoutineFirstCollectionIsCoalesced() {
|
||||
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(1); LocalDateTime now=SelfopSupport.utcNow();
|
||||
assertFalse(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(29),now));
|
||||
assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(30),now));
|
||||
task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
|
||||
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.outbound;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OutboundFactServiceTest {
|
||||
final OutboundLockMapper locks=mock(OutboundLockMapper.class);
|
||||
final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
|
||||
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
|
||||
final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class);
|
||||
final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class);
|
||||
final SelfopOutboundOrderMapper orders=mock(SelfopOutboundOrderMapper.class);
|
||||
final SelfopOutboundItemMapper items=mock(SelfopOutboundItemMapper.class);
|
||||
final OutboundFactService service=new OutboundFactService(locks,tasks,runs,scopes,stages,orders,items);
|
||||
SelfopSyncTask task; SelfopSyncRun run; SelfopSyncStage stage; SelfopSyncScope a,b; SelfopOutboundOrder old;
|
||||
Map<String,OutboundFactService.Lease> leases;
|
||||
SelfopPayloads.Order payload;
|
||||
@BeforeEach void setup() {
|
||||
a=scope(1L,"A"); b=scope(2L,"B"); leases=Map.of("A",new OutboundFactService.Lease("worker",7),"B",new OutboundFactService.Lease("worker",7));
|
||||
task=new SelfopSyncTask(); task.setId(10L); task.setSourceAccount("demo"); task.setScopeId(2L);
|
||||
task.setExecutionPurpose("BUSINESS");
|
||||
task.setTaskRole("WAREHOUSE_SYNC");
|
||||
task.setWarehouseCode("B"); task.setScopeHash("scope"); task.setUpstreamTaskId(9L); task.setApplyState("NOT_STARTED");
|
||||
run=new SelfopSyncRun(); run.setId(20L); run.setTaskId(10L); run.setScopeId(2L); run.setScopeHash("scope"); run.setFenceToken(7L);
|
||||
run.setExecutionPurpose("BUSINESS");
|
||||
run.setAttemptNo(1);
|
||||
payload=new SelfopPayloads.Order("demo","owner","B","delivery",null,100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("3"),"piece")));
|
||||
stage=new SelfopSyncStage(); stage.setId(30L); stage.setStageBatchId(9L); stage.setObjectType("OUTBOUND_ORDER"); stage.setValidationStatus("VALID");
|
||||
stage.setRunId(8L);
|
||||
SelfopSyncRun sourceRun=new SelfopSyncRun(); sourceRun.setId(8L); sourceRun.setTaskId(9L); sourceRun.setExecutionPurpose("BUSINESS");
|
||||
SelfopSyncTask sourceTask=new SelfopSyncTask(); sourceTask.setId(9L); sourceTask.setExecutionPurpose("BUSINESS");
|
||||
sourceRun.setSourceAccount("demo"); sourceTask.setSourceAccount("demo");
|
||||
when(runs.selectById(8L)).thenReturn(sourceRun); when(tasks.selectById(9L)).thenReturn(sourceTask);
|
||||
stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","owner","delivery"))); stage.setWarehouseCode("B"); stage.setObservationSequence(2L);
|
||||
stage.setPayloadJson(SelfopSupport.json(payload)); stage.setObservedAtUtc(SelfopSupport.utcNow());
|
||||
stage.setContentHash(SelfopSupport.hash(payload));
|
||||
SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true);
|
||||
SelfopRoutingManifest.Entry entry=new SelfopRoutingManifest.Entry(); entry.setEffectiveStageId(30L); manifest.getObjects().put(stage.getObjectKeyHash(),entry);
|
||||
task.setRoutingManifestJson(SelfopSupport.json(manifest));
|
||||
old=new SelfopOutboundOrder(); old.setId(40L); old.setWarehouseCode("A"); old.setSourceAccount("demo"); old.setCustomerCode("owner");
|
||||
old.setDeliveryNo("delivery"); old.setRevisionNo(1L); old.setLastObservationSequence(1L); old.setContentHash("old");
|
||||
old.setBusinessDate(LocalDate.of(2026,10,10)); old.setOutboundAtUtc(LocalDateTime.of(2026,10,10,12,0)); old.setSalesEligible(1);
|
||||
when(tasks.selectById(10L)).thenReturn(task); when(locks.task(10L)).thenReturn(task); when(locks.run(20L)).thenReturn(run);
|
||||
when(stages.selectById(30L)).thenReturn(stage); when(locks.stage(30L)).thenReturn(stage);
|
||||
when(orders.selectOne(any())).thenReturn(old); when(locks.order("demo","owner","delivery")).thenReturn(old);
|
||||
when(locks.warehouse("demo","A")).thenReturn(a); when(locks.warehouse("demo","B")).thenReturn(b);
|
||||
when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(2L);
|
||||
when(items.selectList(any())).thenReturn(List.of()); when(tasks.selectCount(any())).thenReturn(0L);
|
||||
}
|
||||
private SelfopSyncScope scope(Long id,String warehouse) {
|
||||
SelfopSyncScope s=new SelfopSyncScope(); s.setId(id); s.setWarehouseCode(warehouse); s.setSourceAccount("demo"); s.setDataFamily("OUTBOUND");
|
||||
s.setScopeHash("scope"); s.setSourceTimezone("UTC"); s.setBusinessTimezone("UTC"); s.setScopeJson("{\"quantityUnit\":\"piece\"}");
|
||||
s.setLeaseOwner("worker"); s.setFenceToken(7L); s.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); s.setFactRevision(0L); return s;
|
||||
}
|
||||
@Test void crossWarehouseRevisionInvalidatesBothAndLeavesBarrierUntilFinish() {
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals("B",old.getWarehouseCode()); assertEquals(1L,a.getFactRevision()); assertEquals(1L,b.getFactRevision());
|
||||
assertEquals(1,a.getRebuildRequired()); assertEquals(1,b.getRebuildRequired()); assertEquals("APPLYING",task.getApplyState());
|
||||
verify(tasks,times(2)).insert(any(SelfopSyncTask.class));
|
||||
assertTrue(task.getAffectedWarehouseCodesJson().contains("A")); assertTrue(task.getAffectedWarehouseCodesJson().contains("B"));
|
||||
verify(stages).update(isNull(),any());
|
||||
}
|
||||
@Test void expiredOldWorkerCannotWriteFactOrBarrier() {
|
||||
a.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
|
||||
assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases));
|
||||
verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(tasks,never()).updateById(any(SelfopSyncTask.class));
|
||||
}
|
||||
@Test void newerPersistedDetailForcesRecheckInsteadOfCompletingOldSlice() {
|
||||
when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(3L);
|
||||
assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases));
|
||||
verify(orders,never()).updateById(any(SelfopOutboundOrder.class));
|
||||
}
|
||||
@Test void cancellationRemovesOldDateAndEligibility() {
|
||||
SelfopPayloads.Order cancelled=new SelfopPayloads.Order("demo","owner","B","delivery",null,99,null,null,payload.items());
|
||||
stage.setPayloadJson(SelfopSupport.json(cancelled)); stage.setContentHash(SelfopSupport.hash(cancelled));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertNull(old.getOutboundAtUtc()); assertNull(old.getBusinessDate()); assertEquals(0,old.getSalesEligible());
|
||||
verify(items).delete(any());
|
||||
}
|
||||
@Test void deadTaskRetainsCrossTaskPublicationBarrier() {
|
||||
task.setApplyState("BLOCKED"); task.setStatus("DEAD");
|
||||
when(locks.barriers("demo","B")).thenReturn(List.of(task));
|
||||
assertThrows(IllegalStateException.class,()->service.assertNoBarrier(b));
|
||||
}
|
||||
@Test void incompleteRoutingSetCannotClearBarrier() {
|
||||
assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(),leases));
|
||||
verify(tasks,never()).updateById(any(SelfopSyncTask.class));
|
||||
}
|
||||
@Test void validationCannotMergeOrCompleteFormalTask() {
|
||||
task.setExecutionPurpose("VALIDATION");
|
||||
assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases));
|
||||
assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(30L),leases));
|
||||
verifyNoInteractions(scopes); verify(orders,never()).updateById(any(SelfopOutboundOrder.class));
|
||||
}
|
||||
@Test void businessCannotReuseValidationDetailStage() {
|
||||
SelfopSyncRun validation=new SelfopSyncRun(); validation.setExecutionPurpose("VALIDATION");
|
||||
when(runs.selectById(8L)).thenReturn(validation);
|
||||
assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases));
|
||||
verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(items,never()).delete(any());
|
||||
}
|
||||
@Test void mergeCheckpointCountsPersistedEffectiveReferencesOnceAcrossReplay() {
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
SelfopRoutingManifest saved=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
|
||||
assertEquals(30L,saved.getObjects().get(stage.getObjectKeyHash()).getAppliedStageId());
|
||||
Map<?,?> progress=SelfopSupport.read(task.getProgressJson(),Map.class);
|
||||
assertEquals("MERGE",progress.get("phase"));assertEquals("ORDER",progress.get("unit"));
|
||||
assertEquals(1L,((Number)progress.get("completed")).longValue());assertEquals(1L,((Number)progress.get("total")).longValue());
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue());
|
||||
assertEquals(1L,a.getFactRevision());assertEquals(1L,b.getFactRevision());
|
||||
}
|
||||
@Test void priorAppliedReferencesCarryForwardButOutdatedReferencesAreNotCompleted() {
|
||||
SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
|
||||
SelfopRoutingManifest.Entry prior=new SelfopRoutingManifest.Entry();prior.setEffectiveStageId(31L);prior.setAppliedStageId(31L);
|
||||
manifest.getObjects().put("other-key",prior);task.setRoutingManifestJson(SelfopSupport.json(manifest));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals(2L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue());
|
||||
manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);manifest.getObjects().get("other-key").setEffectiveStageId(32L);task.setRoutingManifestJson(SelfopSupport.json(manifest));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue());
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.outbound;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OutboundPolicyTest {
|
||||
private SelfopPayloads.Order order(String time,Integer status,List<SelfopPayloads.Item> items) {
|
||||
return new SelfopPayloads.Order("demo","owner","warehouse","delivery",null,status,"2020-01-01 00:00:00",time,items);
|
||||
}
|
||||
@Test void actualSkuLinesAreCombinedWithoutForecastOrPackageQuantities() {
|
||||
SelfopPayloads.Order p=order("2026-10-11 09:00:00",100,List.of(
|
||||
new SelfopPayloads.Item("sku","name",new BigDecimal("2"),"piece"),
|
||||
new SelfopPayloads.Item("sku","name",new BigDecimal("3"),"piece")));
|
||||
assertEquals(new BigDecimal("5"),OutboundPolicy.normalize(p).items().get(0).actualQuantity());
|
||||
assertEquals(1,OutboundPolicy.normalize(p).items().size());
|
||||
}
|
||||
@Test void missingQuantityAndMixedUnitCannotBecomeZero() {
|
||||
assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of(new SelfopPayloads.Item("sku",null,null,"piece")))));
|
||||
assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of(
|
||||
new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"),new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"box")))));
|
||||
}
|
||||
@Test void unknownSourceTimezoneNeverUsesWarehouseOrSystemDefault() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai");
|
||||
assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-10-11 09:00:00",100,List.of()),scope));
|
||||
}
|
||||
@Test void dstOverlapAndGapAreExplicitlyRejected() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("America/New_York");
|
||||
assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-11-01 01:30:00",100,List.of()),scope));
|
||||
assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-03-08 02:30:00",100,List.of()),scope));
|
||||
}
|
||||
@Test void cancellationWithQuantityAndNoOutboundDateHasNoSalesTime() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC");
|
||||
assertNull(OutboundPolicy.outboundUtc(order(null,99,List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"))),scope));
|
||||
}
|
||||
@Test void rollingMonthDoesNotInventFirstPartialDayCoverage() {
|
||||
var days=OutboundPolicy.completeDays(LocalDateTime.of(2026,9,12,0,0),LocalDateTime.of(2026,10,12,0,0),"Asia/Shanghai");
|
||||
assertEquals(LocalDate.of(2026,9,13),days.from()); assertEquals(LocalDate.of(2026,10,12),days.until());
|
||||
assertTrue(LocalDate.of(2026,10,11).isBefore(days.until()));
|
||||
}
|
||||
@Test void disconnectedBackfillDoesNotJumpOverCoverageGap() {
|
||||
LocalDateTime start=LocalDateTime.of(2026,9,12,0,0);
|
||||
var result=OutboundCandidateBuilder.contiguous(start,List.of(
|
||||
new OutboundCandidateBuilder.Coverage(start,start.plusDays(1)),
|
||||
new OutboundCandidateBuilder.Coverage(start.plusDays(2),start.plusDays(3))));
|
||||
assertEquals(start.plusDays(1),result.until());
|
||||
}
|
||||
@Test void newDiscoverySliceUsesHalfOpenUtcBoundaries() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC");
|
||||
LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1);
|
||||
assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 11:59:59",100,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:00:00",100,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:59:59",100,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 13:00:00",100,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
}
|
||||
@Test void endpointReturnedByAdjacentSourceSlicesBelongsOnlyToTheSecondInternalSlice() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC");
|
||||
LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), middle=from.plusHours(1), until=middle.plusHours(1);
|
||||
var p=order("2026-10-11 13:00:00",100,List.of());
|
||||
assertFalse(OutboundPolicy.belongsToSlice(p,scope,from,middle,false,"SCHEDULED"));
|
||||
assertTrue(OutboundPolicy.belongsToSlice(p,scope,middle,until,false,"SCHEDULED"));
|
||||
}
|
||||
@Test void correctedKnownDateAndCancellationRemainApplicableOutsideTheSlice() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC");
|
||||
LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1);
|
||||
var corrected=order("2026-10-01 00:00:00",100,List.of());
|
||||
assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,true,"SCHEDULED"));
|
||||
assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,false,"RECHECK"));
|
||||
assertTrue(OutboundPolicy.belongsToSlice(order(null,99,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
}
|
||||
@Test void unconfirmedTimezoneRetainsEvidenceWithoutAssumingUtc() {
|
||||
SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai");
|
||||
LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1);
|
||||
assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-01 00:00:00",100,List.of()),scope,from,until,false,"SCHEDULED"));
|
||||
assertNull(scope.getSourceTimezone());
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */
|
||||
class AnalyticsQueryServiceTest {
|
||||
private static final String SH="a".repeat(64), IH="b".repeat(64);
|
||||
private static final LocalDate D=LocalDate.of(2026,10,12);
|
||||
private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z");
|
||||
private AnalyticsReadMapper mapper;
|
||||
private AnalyticsQueryService service;
|
||||
@BeforeEach void setUp(){
|
||||
mapper=mock(AnalyticsReadMapper.class); service=at(NOW);
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
when(mapper.count(any())).thenReturn(1L);
|
||||
when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40)))));
|
||||
when(mapper.inventoryCount(any(),any())).thenReturn(1L);
|
||||
doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).complete30()?"150":null))).when(mapper).inventoryPage(any(),any());
|
||||
when(mapper.excluded(any())).thenReturn(List.of());
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40))));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35))));
|
||||
when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of());
|
||||
}
|
||||
@Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){
|
||||
when(mapper.count(any())).thenReturn(240L);
|
||||
var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null));
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);
|
||||
verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture());
|
||||
assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1));
|
||||
var p=cap.getValue(); assertEquals("SUMMARY",p.kind()); assertEquals(11,p.salesRun()); assertEquals(22,p.inventoryRun());
|
||||
assertEquals(D.minusDays(2),p.from()); assertEquals(D.plusDays(1),p.until());
|
||||
assertEquals("demo",p.account()); assertEquals("W",p.warehouse()); assertNull(p.customer());
|
||||
assertEquals(20,p.size()); assertEquals(0,p.offset()); assertEquals(240,response.total()); assertEquals(12,response.pages());
|
||||
verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString());
|
||||
verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any());
|
||||
}
|
||||
@Test void twoZeroAndTodayThreeKeepFormalNullObservedFiveAndAscendingWholeAxis(){
|
||||
when(mapper.daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1))).thenReturn(List.of(
|
||||
new Daily("C","SKU",D,n("3"),1L,"PCS"),new Daily("C","SKU",D.minusDays(2),n("2"),1L,"PCS")));
|
||||
var response=service.dailySales(chart("LAST_3_DAYS",null,null,"C","SKU"));
|
||||
assertEquals(List.of(D.minusDays(2),D.minusDays(1),D),response.points().stream().map(AnalyticsResponses.Point::businessDate).toList());
|
||||
assertEquals(List.of("COMPLETE","COMPLETE","PARTIAL"),response.points().stream().map(AnalyticsResponses.Point::dataStatus).toList());
|
||||
assertEquals(n("2"),response.points().get(0).outboundQuantity()); assertEquals(BigDecimal.ZERO,response.points().get(1).outboundQuantity());
|
||||
assertNull(response.points().get(2).outboundQuantity()); assertEquals(n("3"),response.points().get(2).observedQuantity());
|
||||
assertNull(response.summary().outboundQuantity()); assertEquals(n("5"),response.summary().observedQuantity());
|
||||
assertEquals("PARTIAL",response.summary().dataStatus()); assertEquals(2,response.summary().completeDayCount());
|
||||
assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit());
|
||||
verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1));
|
||||
verify(mapper,never()).page(any()); verify(mapper,never()).count(any());
|
||||
var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0);
|
||||
assertEquals(response.summary().observedQuantity(),summary.observedQuantity());
|
||||
assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus());
|
||||
}
|
||||
@Test void dailyLimitIsInclusive366AndRejects367BeforeReadingBusinessRows(){
|
||||
assertEquals(366,service.dailySales(chart("CUSTOM",D.minusDays(365),D,"C","SKU")).points().size());
|
||||
verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(365),D.plusDays(1)); clearInvocations(mapper);
|
||||
assertThrows(BizException.class,()->service.dailySales(chart("CUSTOM",D.minusDays(366),D,"C","SKU")));
|
||||
verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString());
|
||||
verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any());
|
||||
}
|
||||
@Test void dailyRequiresExactOwnerSkuAndRejectsMissingCandidate(){
|
||||
assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,null,"SKU")));
|
||||
assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C",null)));
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null); when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(null);
|
||||
assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C","SKU")));
|
||||
verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any());
|
||||
}
|
||||
@Test void historyBeforeInventoryEvidenceStaysUnknownAndNoneCannotReviveIt(){
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null);
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(1)).plusHours(16)));
|
||||
var response=service.dailySales(chart("CUSTOM",D.minusDays(3),D.minusDays(2),"C","SKU"));
|
||||
assertNull(response.summary().outboundQuantity()); assertNull(response.summary().observedQuantity());
|
||||
assertTrue(response.points().stream().allMatch(p->"UNKNOWN".equals(p.dataStatus())&&p.reasonCodes().contains("SKU_HISTORY_UNKNOWN")));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","NONE",time(D.minusDays(100))));
|
||||
assertNull(service.dailySales(chart("TODAY",null,null,"C","SKU")).summary().observedQuantity());
|
||||
}
|
||||
@Test void foreignUnitsConflictsAndUnknownSourceCannotProduceSales(){
|
||||
for(String unit:List.of("BOX")){
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile(unit,"VALID_OUTBOUND",time(D.minusDays(40))));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("BOX","INVENTORY_OBSERVATION",time(D.minusDays(40))));
|
||||
var r=service.dailySales(chart("TODAY",null,null,"C","SKU"));
|
||||
assertNull(r.summary().observedQuantity()); assertTrue(r.summary().reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT"));
|
||||
}
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40))));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile(null,"INVENTORY_OBSERVATION",time(D.minusDays(40))));
|
||||
when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of(new Daily("C","SKU",D.minusDays(1),n("4"),1L,"PCS")));
|
||||
var independent=service.dailySales(chart("CUSTOM",D.minusDays(1),D.minusDays(1),"C","SKU"));
|
||||
assertEquals(0,n("4").compareTo(independent.summary().outboundQuantity()));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(40))));
|
||||
when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of());
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS",null));
|
||||
var r=service.dailySales(chart("TODAY",null,null,"C","SKU"));
|
||||
assertEquals("SOURCE_TIMEZONE_UNKNOWN",r.context().salesStatus()); assertNull(r.summary().observedQuantity());
|
||||
}
|
||||
@Test void contextExpiresAtThirtyMinutesAndOldSalesDeadlineClipsIt(){
|
||||
assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt());
|
||||
assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options()));
|
||||
Instant deadline=NOW.plusSeconds(600);
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),deadline.minus(Duration.ofDays(7)),"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
assertEquals(deadline,service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt());
|
||||
assertThrows(BizException.class,()->at(deadline).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options()));
|
||||
}
|
||||
@Test void salesRetentionCannotBeConfiguredBelowSevenDays(){
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"retentionDays",1);
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),NOW.minus(Duration.ofDays(2)),"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt());
|
||||
}
|
||||
@Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){
|
||||
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
|
||||
for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){
|
||||
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose()));
|
||||
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d());
|
||||
}
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any());
|
||||
assertTrue(cap.getAllValues().stream().noneMatch(AnalyticsSqlRequest::complete30));
|
||||
}
|
||||
@Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){
|
||||
Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600);
|
||||
var first=at(before).salesSummary(chart("TODAY",null,null,null,null));
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L)));
|
||||
var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2));
|
||||
assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate());
|
||||
assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId());
|
||||
verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44);
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); assertEquals(20,cap.getValue().offset());
|
||||
}
|
||||
@Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L)));
|
||||
var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId());
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2));
|
||||
assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture());
|
||||
assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown());
|
||||
}
|
||||
@Test void hashScopePurposeAndPublicationMustMatch(){
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,IH,IH,NOW,1),options()));
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(9,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options()));
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"READY","BUSINESS","Asia/Shanghai"));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options()));
|
||||
when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","VALIDATION","Asia/Shanghai"));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any());
|
||||
}
|
||||
@Test void warehouseMismatchAndMissingWarehouseAreRejected(){
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo",null,null,null,null,null,null,null,null,1,20,null,null,null,null,null),options()));
|
||||
var foreign=scope(1,"OUTBOUND",SH,11L);
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(new Scope(foreign.id(),"demo","OTHER",foreign.dataFamily(),SH,"Asia/Shanghai","Asia/Shanghai",
|
||||
93600,11L,1L,1L,false,foreign.knownOrderRoundFinishedAtUtc(),foreign.historyRoundFinishedAtUtc(),7),scope(2,"INVENTORY",IH,22L)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any());
|
||||
}
|
||||
@Test void clockBaselineMustNotBeFutureOrBeforePublicationAndPagesNeedContext(){
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.plusSeconds(1),1),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(7200),1),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,null,2),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,2,20,null,null,null,null,null),options()));
|
||||
}
|
||||
@Test void zeroWinsOverUnknownUnitButDynamicStaleZeroDoesNot(){
|
||||
doAnswer(inv->List.of(inventory(false,"0",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),null,null))).when(mapper).inventoryPage(any(),any());
|
||||
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals(n("0.0"),row.inventoryCoverageDays());assertEquals("UNIT_UNKNOWN_OR_CONFLICT",row.salesCoverage());assertFalse(row.observationEligible());
|
||||
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93500),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
var later=at(NOW.plusSeconds(200)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options());
|
||||
assertEquals("STALE",later.context().inventoryStatus());assertNull(later.records().get(0).inventoryCoverageDays());assertNull(later.records().get(0).availableQuantity());
|
||||
}
|
||||
@Test void fixedMetricWindowsNeverFollowChartPeriods(){
|
||||
var response=service.inventoryCoverage(metrics(null),options()); var row=response.records().get(0);
|
||||
assertEquals(n("60.0"),row.inventoryCoverageDays()); assertEquals(0,n("5").compareTo(row.averageDailyOutboundQuantity()));
|
||||
assertEquals(n("300"),row.availableQuantity()); assertEquals(n("20"),row.frozenQuantity()); assertEquals(n("30"),row.transitQuantity());
|
||||
assertEquals(D.minusDays(40),row.observedHistoryStartDate());
|
||||
assertEquals(30,response.context().calculationWindowDays()); assertEquals(D.minusDays(30),response.context().rangeStartDate());
|
||||
var hot=service.hotSkus(metrics(7)); assertEquals(7,hot.context().calculationWindowDays()); assertEquals(D.minusDays(7),hot.context().rangeStartDate());
|
||||
assertThrows(BizException.class,()->service.hotSkus(metrics(15)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(30),options()));
|
||||
assertThrows(BizException.class,()->service.slowSkus(chart("TODAY",null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.hotSkus(chart("LAST_3_DAYS",null,null,null,null)));
|
||||
}
|
||||
@Test void unknownInventoryDoesNotReturnZeroCoverage(){
|
||||
doReturn(List.of(inventory(true,null,true,time(D.minusDays(40)),"150"))).when(mapper).inventoryPage(any(),any());
|
||||
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality());
|
||||
}
|
||||
@Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){
|
||||
doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any());
|
||||
var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status());
|
||||
doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any());
|
||||
var zero=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals("NO_OUTBOUND_30_DAYS",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
|
||||
}
|
||||
@Test void rebuildBarrierKeepsVersionButDisablesTrustedMetrics(){
|
||||
when(mapper.applyBarriers("demo","W")).thenReturn(1L); var result=service.inventoryCoverage(metrics(null),options());
|
||||
assertTrue(result.context().rebuildRequired()); assertEquals("REBUILD_REQUIRED",result.context().salesStatus());
|
||||
assertEquals(11L,result.context().salesRunId()); assertNull(result.records().get(0).inventoryCoverageDays());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).inventoryPage(cap.capture(),any());
|
||||
assertFalse(cap.getValue().completeWindow()); assertFalse(cap.getValue().complete30());
|
||||
}
|
||||
@Test void topOneHundredClipsFinalSqlPageAndSkipsBeyondIt(){
|
||||
when(mapper.count(any())).thenReturn(100L);
|
||||
service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,2,75,11L,22L,SH,IH,NOW));
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).page(cap.capture());
|
||||
assertEquals(75,cap.getValue().offset()); assertEquals(25,cap.getValue().size());
|
||||
assertTrue(service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,3,50,11L,22L,SH,IH,NOW)).records().isEmpty());
|
||||
verify(mapper,times(1)).page(any());
|
||||
}
|
||||
@Test void readRunsWithinConsistentSnapshot(){
|
||||
var t=AnalyticsQueryService.class.getAnnotation(Transactional.class); assertTrue(t.readOnly()); assertEquals(Isolation.REPEATABLE_READ,t.isolation());
|
||||
}
|
||||
@Test void overviewPreservesUnknownDenominatorAndDoesNotReadCurrentPage(){
|
||||
Map<String,Object> totals=new HashMap<>();totals.put("sku_count",3L);
|
||||
for(String f:List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")){
|
||||
totals.put(f+"_known",n("100"));totals.put(f+"_unknown",0L);
|
||||
}
|
||||
totals.put("physical_quantity_unknown",1L);totals.put("physical_sku_known",n("2"));
|
||||
totals.put("no_outbound_count",1L);totals.put("no_outbound_physical",n("40"));totals.put("no_outbound_unknown",1L);
|
||||
totals.put("no_outbound_unknown_physical",n("20"));totals.put("above_threshold_count",0L);totals.put("above_threshold_physical",n("0"));totals.put("above_threshold_unknown",3L);
|
||||
when(mapper.ownerTotals(any(),any())).thenReturn(totals);
|
||||
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null);
|
||||
var response=service.ownerOverview(q,options());
|
||||
assertEquals(3L,response.skuCount());assertNull(response.physicalQuantity().overallTotal());assertEquals(n("100"),response.physicalQuantity().knownSubtotal());
|
||||
assertNull(response.noOutbound30d().matchedSkuCount());assertNull(response.noOutbound30d().physicalQuantityRatio());assertEquals(1L,response.noOutbound30d().knownMatchedSkuCount());
|
||||
assertEquals(n("100"),response.outboundQuantity30d().overallTotal());assertEquals(List.of("THRESHOLD_NOT_SET"),response.coverageAboveThreshold().reasonCodes());
|
||||
verify(mapper,never()).page(any());verify(mapper,never()).inventoryPage(any(),any());
|
||||
}
|
||||
@Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){
|
||||
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null);
|
||||
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null)));
|
||||
assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0)));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);}
|
||||
private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){
|
||||
BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales);
|
||||
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
|
||||
fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null);
|
||||
}
|
||||
private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC));}
|
||||
private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);}
|
||||
private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);}
|
||||
private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);}
|
||||
private static BigDecimal n(String v){return new BigDecimal(v);}
|
||||
private static LocalDateTime utc(Instant t){return LocalDateTime.ofInstant(t,ZoneOffset.UTC);}
|
||||
private static LocalDateTime time(LocalDate date){return utc(date.atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant());}
|
||||
private static Scope scope(long id,String family,String hash,Long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,utc(NOW.minusSeconds(86400)),utc(NOW.minusSeconds(86400)),7);}
|
||||
private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){
|
||||
String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}";
|
||||
return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D,
|
||||
utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose);
|
||||
}
|
||||
private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);}
|
||||
private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.SqlCommandType;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** MyBatis parses providers and binds parameters; no database or SQL execution. */
|
||||
class AnalyticsReadMapperTest {
|
||||
private static final String NS=AnalyticsReadMapper.class.getName()+".";
|
||||
private static Configuration config(){var c=new Configuration(); c.setMapUnderscoreToCamelCase(true); c.addMapper(AnalyticsReadMapper.class); return c;}
|
||||
private static AnalyticsSqlRequest request(String kind){
|
||||
LocalDate d=LocalDate.of(2026,10,12);
|
||||
return new AnalyticsSqlRequest("demo","W","OWNER","Exact-SKU",11,22,d.minusDays(2),d.plusDays(1),d,d.minusDays(7),
|
||||
d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind);
|
||||
}
|
||||
private static BoundSql bound(Configuration c,String method,Map<String,Object> parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);}
|
||||
private static void immutableOnly(String sql){
|
||||
assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage"));
|
||||
assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER"));
|
||||
}
|
||||
@Test void allMapperStatementsAreSelectAndProvidersBindTheNamedRecord(){
|
||||
var c=config(); assertNotNull(AnalyticsReadMapper.class.getAnnotation(Mapper.class));
|
||||
for(String method:List.of("scopes","publishedRun","page","count","excluded","profile","daily","applyBarriers")){
|
||||
var statement=c.getMappedStatement(NS+method); assertEquals(SqlCommandType.SELECT,statement.getSqlCommandType());
|
||||
Map<String,Object> params=new HashMap<>(); params.put("p",request("HOT")); params.put("id",11L); params.put("run",11L);
|
||||
params.put("account","demo"); params.put("warehouse","W"); params.put("customer","OWNER"); params.put("sku","Exact-SKU");
|
||||
params.put("from",LocalDate.of(2026,10,10)); params.put("until",LocalDate.of(2026,10,13));
|
||||
immutableOnly(statement.getBoundSql(params).getSql());
|
||||
}
|
||||
var b=bound(c,"page",Map.of("p",request("HOT")));
|
||||
var names=b.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList();
|
||||
for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.from","p.until","p.size","p.offset"))assertTrue(names.contains(property),property);
|
||||
assertFalse(b.getSql().contains("#{"));
|
||||
assertEquals(AnalyticsReadRows.Aggregate.class,c.getMappedStatement(NS+"page").getResultMaps().get(0).getType());
|
||||
}
|
||||
@Test void exactDailySqlFiltersEveryIdentityVersionAndHalfOpenDateBeforeAscendingAxis(){
|
||||
var c=config(); var b=bound(c,"daily",Map.of("run",11L,"account","demo","warehouse","W","customer","OWNER","sku","Exact-SKU",
|
||||
"from",LocalDate.of(2026,10,10),"until",LocalDate.of(2026,10,13)));
|
||||
String sql=b.getSql();
|
||||
for(String fragment:List.of("sales_run_id=?","source_account=?","warehouse_code=?","customer_code=?","sku_code=?","business_date>=?","business_date<?"))assertTrue(sql.contains(fragment),fragment);
|
||||
assertTrue(sql.endsWith("ORDER BY business_date")); assertFalse(sql.contains("LIMIT")); assertFalse(sql.contains("DATE(business_date)"));
|
||||
assertEquals(AnalyticsReadRows.Daily.class,c.getMappedStatement(NS+"daily").getResultMaps().get(0).getType());
|
||||
immutableOnly(sql);
|
||||
}
|
||||
@Test void summaryCandidateUnionCountDoesNotReadDailyAndPageOnlyScansSelectedWindow(){
|
||||
var c=config(); var parameters=Map.<String,Object>of("p",request("SUMMARY"));
|
||||
String count=bound(c,"count",parameters).getSql();
|
||||
assertTrue(count.contains(" UNION SELECT customer_code,sku_code")); assertFalse(count.contains("UNION ALL"));
|
||||
assertFalse(count.contains("ll_selfop_sku_daily_sales")); assertFalse(count.contains("ll_selfop_inventory_snapshot"));
|
||||
String page=bound(c,"page",parameters).getSql();
|
||||
assertTrue(page.contains("d.sales_run_id=?")); assertTrue(page.contains("d.business_date>=CAST(? AS DATE) AND d.business_date<CAST(? AS DATE)"));
|
||||
assertTrue(page.contains("GROUP BY b.customer_code,b.sku_code")); assertFalse(page.contains("business_date>=DATE_SUB"));
|
||||
assertFalse(page.contains("ll_selfop_inventory_snapshot"));
|
||||
assertTrue(page.endsWith("ORDER BY observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC LIMIT ? OFFSET ?"));
|
||||
assertTrue(page.indexOf("GROUP BY b.customer_code,b.sku_code")<page.lastIndexOf("LIMIT")); immutableOnly(page);
|
||||
}
|
||||
@Test void metricsPreFilterQualityAndStableSortAggregatesBeforePagination(){
|
||||
var c=config();
|
||||
for(String kind:List.of("HOT","SLOW","INVENTORY")){
|
||||
var p=Map.<String,Object>of("p",request(kind)); String sql=bound(c,"page",p).getSql();
|
||||
assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?"));
|
||||
assertTrue(sql.contains("AND st.product_quality=0"));
|
||||
assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
|
||||
assertTrue(sql.contains("GROUP BY customer_code,sku_code"));
|
||||
assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
|
||||
assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
|
||||
}
|
||||
String hot=bound(c,"page",Map.of("p",request("HOT"))).getSql();
|
||||
assertTrue(hot.contains("FROM result WHERE window_quantity>0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT"));
|
||||
String slow=bound(c,"page",Map.of("p",request("SLOW"))).getSql();
|
||||
assertTrue(slow.contains("inventory_quality='VALID' AND available_quantity>0"));
|
||||
assertTrue(slow.contains("evidence_at_utc<=CONVERT_TZ"));
|
||||
assertTrue(slow.contains("ORDER BY window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC LIMIT"));
|
||||
for(String kind:List.of("HOT","SLOW")){
|
||||
String count=bound(c,"count",Map.of("p",request(kind))).getSql(); assertTrue(count.contains("SELECT LEAST(COUNT(*),100) FROM result")); assertFalse(count.contains(" LIMIT "));
|
||||
}
|
||||
}
|
||||
@Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){
|
||||
String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql();
|
||||
assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL"));
|
||||
assertFalse(sql.contains("first_seen_at_utc")); assertTrue(sql.contains("COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS'"));
|
||||
assertTrue(sql.contains("s.quantity_unit<>i.quantity_unit"));
|
||||
// Comparing timestamps before DATE projection prevents 16:00 evidence from
|
||||
// turning an 08:00 covered-until on the same day into an observed zero.
|
||||
assertTrue(sql.contains("AND GREATEST(evidence_at_utc,CAST(? AS DATETIME))<CAST(? AS DATETIME) THEN GREATEST(CAST(? AS DATE),DATE("));
|
||||
assertTrue(sql.contains("evidence_at_utc IS NOT NULL AND CAST(? AS DATETIME) IS NOT NULL AND CAST(? AS DATETIME) IS NOT NULL"));
|
||||
assertTrue(sql.contains("CASE WHEN c.unit_valid=1 AND c.observed_count>0 THEN COALESCE(c.observed_sum,0) END observed_quantity"));
|
||||
assertTrue(sql.contains("WHEN ?=0 THEN 'STALE'"));
|
||||
assertTrue(sql.contains("st.available_quantity<0")); assertTrue(sql.contains("st.available_quantity IS NULL"));
|
||||
}
|
||||
@Test void publishedRunRejectsValidationAndAccountDiscoveryAndBarrierSurvivesDeadTask(){
|
||||
var c=config(); String run=bound(c,"publishedRun",Map.of("id",11L)).getSql();
|
||||
assertTrue(run.startsWith("SELECT id,task_id,scope_id,")); assertTrue(run.contains("status='PUBLISHED'"));
|
||||
assertTrue(run.contains("run_role='WAREHOUSE_SYNC'")); assertTrue(run.contains("execution_purpose='BUSINESS'"));
|
||||
assertEquals(AnalyticsReadRows.Run.class,c.getMappedStatement(NS+"publishedRun").getResultMaps().get(0).getType());
|
||||
String barrier=bound(c,"applyBarriers",Map.of("account","demo","warehouse","W")).getSql();
|
||||
assertTrue(barrier.contains("'APPLYING','BLOCKED'")); assertTrue(barrier.contains("JSON_CONTAINS")); assertFalse(barrier.contains("status="));
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Executes the production provider SQL on synthetic, process-local H2 tables only.
|
||||
* H2's CONVERT_TZ is supplied with explicit offsets. Its repeatedly referenced CTE prepared
|
||||
* plan loses parameter values, so page SQL uses typed, escaped fixture literals from the
|
||||
* original BoundSql mappings. Candidate counts and separate binding contracts remain prepared.
|
||||
* No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */
|
||||
public class AnalyticsSqlSemanticTest {
|
||||
private Connection schema;
|
||||
private SqlSession session;
|
||||
private AnalyticsReadMapper mapper;
|
||||
private Configuration configuration;
|
||||
private int statementSequence;
|
||||
private long nextId=1;
|
||||
private static final LocalDate D=LocalDate.of(2026,10,12);
|
||||
@BeforeEach void setup() throws Exception {
|
||||
String url="jdbc:h2:mem:selfop_query_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL";
|
||||
var ds=new UnpooledDataSource("org.h2.Driver",url,"sa","");
|
||||
schema=ds.getConnection();
|
||||
try(var statement=schema.createStatement()) {
|
||||
statement.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
|
||||
statement.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
|
||||
statement.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
|
||||
statement.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))");
|
||||
}
|
||||
configuration=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));
|
||||
configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AnalyticsReadMapper.class);
|
||||
session=new SqlSessionFactoryBuilder().build(configuration).openSession();mapper=session.getMapper(AnalyticsReadMapper.class);
|
||||
}
|
||||
@AfterEach void close() throws Exception {if(session!=null)session.close();if(schema!=null)schema.close();}
|
||||
public static LocalDateTime convertZone(LocalDateTime value,String from,String to) {
|
||||
return value==null?null:value.atOffset(ZoneOffset.of(from)).withOffsetSameInstant(ZoneOffset.of(to)).toLocalDateTime();
|
||||
}
|
||||
@Test void sqlReturnsTwoCompleteDaysAndTodayPartialObservedFive() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(35),0),"PCS");
|
||||
daily(11,"W","C","SKU",D.minusDays(2),"2");daily(11,"W","C","SKU",D,"3");
|
||||
var p=request("SUMMARY",D.minusDays(2),D.plusDays(1),null,null,utc(D,8),0,20);
|
||||
assertEquals(1,mapper.count(p));var row=page(p).get(0);
|
||||
assertEquals(0,new BigDecimal("5").compareTo(row.observedQuantity()));
|
||||
assertEquals(2,row.completeDayCount());assertEquals(1,row.partialDayCount());assertEquals(0,row.unknownDayCount());
|
||||
}
|
||||
@Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception {
|
||||
profile(22,"C","LATE","INVENTORY_OBSERVATION",utc(D,16),"PCS");
|
||||
profile(22,"C","NO-EVIDENCE","NONE",null,"PCS");
|
||||
var rows=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20));
|
||||
var unknown=rows.stream().filter(r->"NO-EVIDENCE".equals(r.skuCode())).findFirst().orElseThrow();
|
||||
assertNull(unknown.observedQuantity());assertEquals(1,unknown.unknownDayCount());
|
||||
var row=rows.stream().filter(r->"LATE".equals(r.skuCode())).findFirst().orElseThrow();
|
||||
assertNull(row.observedQuantity());assertEquals(0,row.completeDayCount());assertEquals(0,row.partialDayCount());assertEquals(1,row.unknownDayCount());
|
||||
// A real confirmed row outside the continuous watermark is still observed, never complete today.
|
||||
daily(11,"W","C","LATE",D,"3");session.clearCache();
|
||||
row=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20)).get(0);
|
||||
assertEquals(0,new BigDecimal("3").compareTo(row.observedQuantity()));assertEquals(1,row.partialDayCount());
|
||||
}
|
||||
@Test void intervalSortingIncludesZeroCandidatesBeforePagingAndNeverMergesOwners() throws Exception {
|
||||
for(String owner:List.of("A","B","C"))profile(22,owner,"SAME","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","B","SAME",D.minusDays(1),"5");
|
||||
var first=request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,1);
|
||||
assertEquals(3,mapper.count(first));assertEquals("B",page(first).get(0).customerCode());
|
||||
assertEquals("A",page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),1,1)).get(0).customerCode());
|
||||
var zero=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),2,1)).get(0);
|
||||
assertEquals("C",zero.customerCode());assertEquals(0,zero.observedQuantity().signum());assertEquals(1,zero.completeDayCount());
|
||||
}
|
||||
@Test void exactFiltersExcludeOtherWarehouseVersionDatesAndQualityNeverMultipliesSales() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"150");daily(11,"OTHER","C","SKU",D.minusDays(1),"999");
|
||||
daily(33,"W","C","SKU",D.minusDays(1),"999");daily(11,"W","C","SKU",D.minusDays(31),"999");
|
||||
stock("C","SKU",0,"300");stock("C","SKU",1,"999");
|
||||
var row=page(request("INVENTORY",D.minusDays(30),D,"C","SKU",utc(D,8),0,20)).get(0);
|
||||
assertEquals(0,new BigDecimal("150").compareTo(row.sales30Quantity()));
|
||||
assertEquals(0,new BigDecimal("300").compareTo(row.availableQuantity()));assertEquals(1L,row.windowSkuOrderCount());
|
||||
assertEquals(0,mapper.count(request("SUMMARY",D.minusDays(1),D,"OTHER","SKU",utc(D,8),0,20)));
|
||||
}
|
||||
@Test void inventoryUnitConflictPreservesPureSalesButBlocksMixedMetricEligibility() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");daily(11,"W","C","SKU",D.minusDays(1),"5");
|
||||
profile(11,"C","UNKNOWN-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","UNKNOWN-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null);
|
||||
daily(11,"W","C","UNKNOWN-UNIT",D.minusDays(1),"5");
|
||||
var rows=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,20));
|
||||
assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0));
|
||||
var row=rows.get(0);
|
||||
assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount());
|
||||
assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty());
|
||||
}
|
||||
@Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"150");
|
||||
stockExact("SKU",0,"300","250","20","30","VALID",null);
|
||||
stockExact("SKU",1,"60","50","5","10","VALID",null);
|
||||
var row=inventory(null,null,null,0,20).get(0);
|
||||
assertDecimal("360",row.physicalQuantity());assertDecimal("250",row.availableQuantity());assertDecimal("60",row.defectivePhysicalQuantity());
|
||||
assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d());
|
||||
assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount());
|
||||
}
|
||||
@Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING");
|
||||
stockExact("SKU",7,"3","0","0","0","VALID",null);
|
||||
var row=inventory(null,null,null,0,20).get(0);
|
||||
assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity());
|
||||
assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays());
|
||||
assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size());
|
||||
}
|
||||
@Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null);
|
||||
var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0);
|
||||
assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size());
|
||||
assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty());
|
||||
}
|
||||
@Test void thresholdUsesExactCrossProductBeforePagingAndOwnerTotalsUseAllCandidates() throws Exception {
|
||||
for(int i=0;i<105;i++){
|
||||
String sku=String.format("SKU-%03d",i);profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact(sku,0,"301","301","0","0","VALID",null);daily(11,"W","C",sku,D.minusDays(1),"30");
|
||||
}
|
||||
profile(22,"C","BOUNDARY","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact("BOUNDARY",0,"300.000001","300.000001","0","0","VALID",null);daily(11,"W","C","BOUNDARY",D.minusDays(1),"30");
|
||||
var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size());
|
||||
assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays());
|
||||
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20);
|
||||
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300);
|
||||
assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
|
||||
Map<String,Object> totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue());
|
||||
assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known"));
|
||||
}
|
||||
@Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception {
|
||||
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null);
|
||||
daily(11,"W","C","NEW",D.minusDays(1),"5");
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound());
|
||||
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30));
|
||||
assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical"));
|
||||
}
|
||||
@Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception {
|
||||
for(String owner:List.of("A%","A_test","ABC","abc")){
|
||||
profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
}
|
||||
var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20);
|
||||
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null)));
|
||||
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null)));
|
||||
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null)));
|
||||
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue());
|
||||
}
|
||||
@Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null);
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity());
|
||||
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30));
|
||||
assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
}
|
||||
private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));}
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
Map<String,Object> result=new HashMap<>();((Map<String,Object>)literalQuery("ownerTotals",p,o).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result;
|
||||
}
|
||||
private List<?> literalQuery(String method,AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
String namespace=AnalyticsReadMapper.class.getName()+"."+method;var original=configuration.getMappedStatement(namespace);
|
||||
Map<String,Object> args=Map.of("p",p,"o",o);String literal=render(original.getBoundSql(args),args);String id=namespace+".fixture."+(++statementSequence);
|
||||
configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,new org.apache.ibatis.builder.StaticSqlSource(configuration,literal),
|
||||
org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
|
||||
}
|
||||
private void stockExact(String sku,int quality,String physical,String available,String frozen,String transit,String status,String reason)throws Exception{
|
||||
try(var p=schema.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,quality);
|
||||
String[] quantities={physical,available,frozen,transit};for(int i=0;i<4;i++)p.setBigDecimal(7+i,quantities[i]==null?null:new BigDecimal(quantities[i]));
|
||||
p.setObject(11,utc(D,8));p.setString(12,status);p.setString(13,reason);p.executeUpdate();
|
||||
}
|
||||
}
|
||||
private List<AnalyticsReadRows.Aggregate> page(AnalyticsSqlRequest request) {
|
||||
String namespace=AnalyticsReadMapper.class.getName()+".page";
|
||||
var original=configuration.getMappedStatement(namespace);
|
||||
Map<String,Object> parameters=Map.of("p",request);
|
||||
var bound=original.getBoundSql(parameters);
|
||||
String literal=render(bound,parameters);
|
||||
String id=namespace+".syntheticLiteral."+(++statementSequence);
|
||||
var source=new org.apache.ibatis.builder.StaticSqlSource(configuration,literal);
|
||||
configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,source,
|
||||
org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());
|
||||
return session.selectList(id);
|
||||
}
|
||||
private static String render(org.apache.ibatis.mapping.BoundSql bound,Map<String,Object> parameters) {
|
||||
var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(parameters);
|
||||
String sql=bound.getSql();StringBuilder result=new StringBuilder();int index=0;boolean quoted=false;
|
||||
for(int i=0;i<sql.length();i++) {
|
||||
char c=sql.charAt(i);
|
||||
if(c=='\'') {
|
||||
result.append(c);
|
||||
if(quoted&&i+1<sql.length()&&sql.charAt(i+1)=='\''){result.append(sql.charAt(++i));continue;}
|
||||
quoted=!quoted;continue;
|
||||
}
|
||||
if(c!='?'||quoted){result.append(c);continue;}
|
||||
var mapping=bound.getParameterMappings().get(index++);
|
||||
Object value=bound.hasAdditionalParameter(mapping.getProperty())?bound.getAdditionalParameter(mapping.getProperty()):meta.getValue(mapping.getProperty());
|
||||
result.append(literal(value));
|
||||
}
|
||||
assertFalse(quoted);assertEquals(bound.getParameterMappings().size(),index,"Every bound value must be consumed exactly once");
|
||||
return result.toString();
|
||||
}
|
||||
private static String literal(Object value) {
|
||||
if(value==null)return "NULL";
|
||||
if(value instanceof Boolean b)return b?"1":"0";
|
||||
if(value instanceof Number n)return n instanceof BigDecimal decimal?decimal.toPlainString():n.toString();
|
||||
if(value instanceof LocalDate d)return "DATE '"+d+"'";
|
||||
if(value instanceof LocalDateTime t)return "TIMESTAMP '"+t.toString().replace('T',' ')+"'";
|
||||
if(value instanceof String s)return "'"+s.replace("'","''")+"'";
|
||||
throw new IllegalArgumentException("Unsupported synthetic parameter type: "+value.getClass());
|
||||
}
|
||||
private AnalyticsSqlRequest request(String kind,LocalDate from,LocalDate until,String owner,String sku,LocalDateTime coveredUntil,long offset,int size) {
|
||||
return new AnalyticsSqlRequest("demo","W",owner,sku,11,22,from,until,D,D.minusDays(30),D.minusDays(30),D,
|
||||
utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind);
|
||||
}
|
||||
private static LocalDateTime utc(LocalDate d,int hour){return d.atTime(hour,0).atZone(ZoneId.of("Asia/Shanghai")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();}
|
||||
private void profile(long run,String owner,String sku,String evidence,LocalDateTime at,String unit)throws Exception {
|
||||
try(var p=schema.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,"合成商品");
|
||||
p.setString(7,unit);p.setString(8,evidence);p.setLong(9,1);p.setObject(10,at);p.executeUpdate();
|
||||
}
|
||||
}
|
||||
private void daily(long run,String warehouse,String owner,String sku,LocalDate date,String quantity)throws Exception {
|
||||
try(var p=schema.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,nextId++);p.setLong(2,run);p.setString(3,"demo");p.setString(4,warehouse);p.setString(5,owner);p.setString(6,sku);
|
||||
p.setObject(7,date);p.setBigDecimal(8,new BigDecimal(quantity));p.setLong(9,1);p.setString(10,"PCS");p.executeUpdate();
|
||||
}
|
||||
}
|
||||
private void stock(String owner,String sku,int quality,String available)throws Exception {
|
||||
try(var p=schema.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setInt(6,quality);
|
||||
p.setBigDecimal(7,new BigDecimal("120"));p.setBigDecimal(8,new BigDecimal(available));p.setBigDecimal(9,new BigDecimal("20"));
|
||||
p.setBigDecimal(10,new BigDecimal("30"));p.setObject(11,utc(D,8));p.setString(12,"VALID");p.setString(13,null);p.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Executes narrow operations-page reads against process-local synthetic metadata only. */
|
||||
class CollectionDailySamplesSqlTest {
|
||||
@Test void onlyPublishedBusinessInventoryRepresentativesCountWithinTheRequestedWarehouseAndDates() throws Exception {
|
||||
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:daily_samples_"+UUID.randomUUID()+";MODE=MySQL","sa","");
|
||||
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE ll_selfop_sync_scope(id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),data_family VARCHAR(24))");
|
||||
sql.execute("CREATE TABLE ll_selfop_sync_run(scope_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),status VARCHAR(24),execution_purpose VARCHAR(24),run_role VARCHAR(24),coverage_status VARCHAR(24),inventory_sample_rule_version VARCHAR(32),inventory_daily_sample_date DATE)");
|
||||
sql.execute("INSERT INTO ll_selfop_sync_scope VALUES (1,'primary','A','INVENTORY'),(2,'primary','B','INVENTORY'),(3,'other','A','INVENTORY'),(4,'primary','A','OUTBOUND')");
|
||||
String insert="INSERT INTO ll_selfop_sync_run VALUES (?,?,?,?,?,?,?,?,?)";
|
||||
try(var row=connection.prepareStatement(insert)) {
|
||||
Object[][] fixtures={
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-09-01"},
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-10"},
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-11"},
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-12"},
|
||||
{2,"primary","B","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-09"},
|
||||
{3,"other","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-08"},
|
||||
{4,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-07"},
|
||||
{1,"primary","A","FAILED","BUSINESS","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-06"},
|
||||
{1,"primary","A","PUBLISHED","VALIDATION","WAREHOUSE_SYNC","COMPLETE","DAILY_SCHEDULED_FIRST_V1","2026-10-05"},
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","PARTIAL","DAILY_SCHEDULED_FIRST_V1","2026-10-04"},
|
||||
{1,"primary","A","PUBLISHED","BUSINESS","WAREHOUSE_SYNC","COMPLETE",null,null}
|
||||
};
|
||||
for(Object[] values:fixtures){for(int i=0;i<values.length;i++)row.setObject(i+1,values[i]);row.executeUpdate();}
|
||||
}
|
||||
var configuration=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));
|
||||
configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(CollectionReadMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var mapper=session.getMapper(CollectionReadMapper.class);
|
||||
var samples=mapper.inventoryDailySamples("primary",List.of("A"),LocalDate.of(2026,9,12),LocalDate.of(2026,10,12));
|
||||
assertEquals(List.of(new CollectionReadRows.InventorySample("A",LocalDate.of(2026,10,10)),
|
||||
new CollectionReadRows.InventorySample("A",LocalDate.of(2026,10,11))),samples);
|
||||
assertEquals(List.of(new CollectionReadRows.InventorySample("A",LocalDate.of(2026,9,1))),
|
||||
mapper.inventoryAccumulationStarts("primary",List.of("A")));
|
||||
assertTrue(mapper.inventoryDailySamples("primary",List.of("MISSING"),LocalDate.of(2026,9,12),LocalDate.of(2026,10,12)).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*;
|
||||
|
||||
class CollectionQueryServiceTest {
|
||||
final CollectionReadMapper mapper=mock(CollectionReadMapper.class);
|
||||
final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
|
||||
final LocalDateTime now=LocalDateTime.of(2026,10,12,4,0);
|
||||
CollectionQueryService service;
|
||||
@BeforeEach void setup() {
|
||||
config.setSourceAccount("primary");config.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());
|
||||
service=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(now.toInstant(ZoneOffset.UTC),ZoneOffset.UTC));
|
||||
when(mapper.scopes("primary","A")).thenReturn(List.of(scope()));
|
||||
}
|
||||
CollectionQuery q(Integer page,LocalDateTime upper,Long id) {
|
||||
return new CollectionQuery("primary","A",null,null,null,null,null,null,page,null,upper==null?null:upper.toInstant(ZoneOffset.UTC),id);
|
||||
}
|
||||
Scope scope() {
|
||||
return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600,
|
||||
now.minusMonths(2),now,null,null,0,null,null,null,7,null,null);
|
||||
}
|
||||
Task task(String purpose,String progress,int attempts) {
|
||||
return new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","SCHEDULED",purpose,
|
||||
"hash","DEAD","BLOCKED",attempts,now.minusDays(1),now,null,null,null,null,progress,now,null,now,now);
|
||||
}
|
||||
Attempt run(int attempt,String status,String snapshot) {
|
||||
return new Attempt(90L,7L,"LXCOL-TASK",attempt,status,"DETAILS","BUSINESS",now.minusHours(1),null,null,
|
||||
"FAILED".equals(status)?now:null,null,now.minusSeconds(10),snapshot,"PARTIAL",now.minusDays(1),now,
|
||||
"FAILED".equals(status)?"SOURCE_ERROR":null,null,now.minusHours(1),"A",1L,"hash");
|
||||
}
|
||||
@Test void noPublishedDataStillReturnsConfiguredWarehousesAndStoppedMode() {
|
||||
when(mapper.scopes("primary",null)).thenReturn(List.of());
|
||||
var query=new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null);
|
||||
var result=service.overview(query);
|
||||
assertEquals(2,result.warehouses().size());
|
||||
var row=result.warehouses().get(0);
|
||||
assertEquals("A",row.warehouseName());assertEquals("COLLECTION_OFF",row.disabledReason());
|
||||
assertNull(row.lastPublishedAt());assertEquals("NOT_COLLECTED",row.dataFreshness());
|
||||
assertNull(row.sharedUpstream());assertFalse(row.executionEnabled());
|
||||
}
|
||||
@Test void paginationRequiresBothFrozenUpperBoundFields() {
|
||||
assertThrows(BizException.class,()->service.tasks(q(2,null,null)));
|
||||
assertThrows(BizException.class,()->service.tasks(q(1,now,null)));
|
||||
verify(mapper,never()).tasks(any(),any());
|
||||
}
|
||||
@Test void dailyInventoryCountsCompletedBeijingDaysAndPreservesGapsPerWarehouse() {
|
||||
config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());
|
||||
service=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-11T16:01:00Z"),ZoneOffset.UTC));
|
||||
LocalDate until=LocalDate.of(2026,10,12),from=until.minusDays(30);
|
||||
when(mapper.inventoryDailySamples("primary",List.of("A","B"),from,until)).thenReturn(List.of(
|
||||
new InventorySample("A",until.minusDays(1)),new InventorySample("A",until.minusDays(2)),
|
||||
new InventorySample("B",until.minusDays(3))));
|
||||
when(mapper.inventoryAccumulationStarts("primary",List.of("A","B"))).thenReturn(List.of(
|
||||
new InventorySample("A",until.minusDays(40)),new InventorySample("B",until.minusDays(3))));
|
||||
var rows=service.overview(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null)).warehouses();
|
||||
var a=rows.stream().filter(w->w.warehouseCode().equals("A")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples();
|
||||
var b=rows.stream().filter(w->w.warehouseCode().equals("B")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples();
|
||||
assertEquals(from,a.windowFrom());assertEquals(until,a.windowUntil());assertEquals(30,a.expectedDays());
|
||||
assertEquals(2,a.sampleCount());assertEquals(28,a.missingDates().size());assertFalse(a.missingDates().contains(until.minusDays(1)));
|
||||
assertEquals(until.minusDays(40),a.accumulationStartedOn());assertEquals(1,b.sampleCount());
|
||||
assertTrue(rows.stream().filter(w->w.dataFamily().equals("OUTBOUND")).allMatch(w->w.inventoryDailySamples()==null));
|
||||
verify(mapper,times(1)).inventoryDailySamples("primary",List.of("A","B"),from,until);
|
||||
}
|
||||
@Test void noInventorySamplesShowsThirtyGapsWithoutInventingHistory() {
|
||||
var row=service.overview(q(null,null,null)).warehouses().stream()
|
||||
.filter(w->w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples();
|
||||
assertEquals(0,row.sampleCount());assertEquals(30,row.missingDates().size());assertNull(row.accumulationStartedOn());
|
||||
}
|
||||
@Test void accountAndWarehouseAreValidatedBeforePersistenceRead() {
|
||||
assertThrows(BizException.class,()->service.tasks(new CollectionQuery("other","A",null,null,null,null,null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.tasks(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null)));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void frozenBoundAndBeijingDateFiltersGoToSql() {
|
||||
Bound upper=new Bound(now,99L);
|
||||
when(mapper.tasks(any(),eq(upper))).thenReturn(List.of());
|
||||
var query=new CollectionQuery("primary","A","OUTBOUND",null,null,null,LocalDate.of(2026,10,12),LocalDate.of(2026,10,12),2,20,now.toInstant(ZoneOffset.UTC),99L);
|
||||
var result=service.tasks(query);
|
||||
ArgumentCaptor<CollectionReadMapper.Filter> f=ArgumentCaptor.forClass(CollectionReadMapper.Filter.class);
|
||||
verify(mapper).tasks(f.capture(),eq(upper));
|
||||
assertEquals(LocalDateTime.of(2026,10,11,16,0),f.getValue().from());
|
||||
assertEquals(LocalDateTime.of(2026,10,12,16,0),f.getValue().until());
|
||||
assertEquals(20,f.getValue().offset());assertEquals(99L,result.listUpperId());
|
||||
verify(mapper,never()).taskBound(any());
|
||||
}
|
||||
@Test void runHistoryReadsFrozenTerminalProgressRatherThanTaskCurrentAttempt() {
|
||||
Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED","{\"completed\":2,\"totalKnown\":false,\"unit\":\"ORDER\"}")));
|
||||
when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2)));
|
||||
var result=service.runs(q(null,null,null));
|
||||
assertEquals(2L,result.records().get(0).progress().get("completed"));
|
||||
assertEquals("LXCOL-TASK",result.records().get(0).taskNo());assertEquals(90L,result.records().get(0).runId());
|
||||
assertEquals("TERMINATED",result.records().get(0).executionHealth());
|
||||
}
|
||||
@Test void activeRunFromDifferentAttemptDoesNotReadNewAttemptProgress() {
|
||||
Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FETCHING",null)));
|
||||
when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2)));
|
||||
assertTrue(service.runs(q(null,null,null)).records().get(0).progress().isEmpty());
|
||||
}
|
||||
@Test void progressWhitelistPreservesUnknownCountsAndNeverCreatesTotalPercentage() {
|
||||
var result=service.progress("{\"completed\":3,\"total\":17,\"totalKnown\":false,\"unknownCount\":null,\"unit\":\"ORDER\",\"secret\":\"hidden\",\"percent\":90}");
|
||||
assertEquals(3L,result.get("completed"));assertNull(result.get("total"));assertNull(result.get("unknownCount"));
|
||||
assertFalse(result.containsKey("secret"));assertFalse(result.containsKey("percent"));
|
||||
assertTrue(service.progress("{\"x\":\""+"x".repeat(8192)+"\"}").isEmpty());
|
||||
assertTrue(service.progress("not json").isEmpty());
|
||||
}
|
||||
@Test void unknownUnitsAndNegativeCountsAreNotTrusted() {
|
||||
var result=service.progress("{\"completed\":-1,\"unit\":\"HTTP\",\"total\":3,\"totalKnown\":true}");
|
||||
assertNull(result.get("completed"));assertNull(result.get("unit"));assertEquals(3L,result.get("total"));
|
||||
}
|
||||
@Test void batchCheckpointsKeepReliableCountsWhileNonCountingStagesNeverPretendZeroProgress() {
|
||||
var batch=service.progress("{\"unit\":\"BATCH\",\"completed\":3,\"total\":5,\"totalKnown\":true}");
|
||||
assertEquals("BATCH",batch.get("unit"));assertEquals(3L,batch.get("completed"));assertEquals(5L,batch.get("total"));
|
||||
var waiting=service.progress("{\"unit\":\"NONE\",\"completed\":0,\"total\":0,\"totalKnown\":true}");
|
||||
assertEquals("NONE",waiting.get("unit"));assertNull(waiting.get("completed"));assertNull(waiting.get("total"));
|
||||
assertEquals(false,waiting.get("totalKnown"));
|
||||
}
|
||||
@Test void validationTaskCanNeverAdvertiseBusinessRetry() {
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(task("VALIDATION",null,0));
|
||||
var result=service.taskDetail("LXCOL-TASK",q(null,null,null));
|
||||
assertTrue(result.task().allowedActions().isEmpty());assertEquals("VALIDATION_NOT_RETRYABLE",result.task().actionBlockReason());
|
||||
}
|
||||
@Test void emptyPageReturnsFrozenEmptyUpperBound() {
|
||||
when(mapper.taskBound(any())).thenReturn(null);
|
||||
var result=service.tasks(q(null,null,null));
|
||||
assertEquals(0L,result.listUpperId());assertEquals(now.toInstant(ZoneOffset.UTC),result.listUpperCreatedAt());assertEquals(0,result.total());
|
||||
verify(mapper,never()).tasks(any(),any());
|
||||
}
|
||||
@Test void missingTaskDoesNotLeakOtherWarehouseTask() {
|
||||
assertThrows(BizException.class,()->service.taskDetail("LXCOL-OTHER",q(null,null,null)));
|
||||
}
|
||||
@Test void repeatedRowsUseOneBatchLookupAndNeverFetchPerTask() {
|
||||
Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED",null),run(2,"FETCHING",null)));
|
||||
when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,2)));
|
||||
service.runs(q(null,null,null));
|
||||
verify(mapper,times(1)).tasksByIds("primary",List.of(7L));
|
||||
}
|
||||
@Test void errorPayloadIsNeverExposedToOperationsPage() {
|
||||
Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
var r=new Attempt(90L,7L,"LXCOL-TASK",1,"FAILED","DETAILS","BUSINESS",now.minusHours(1),null,null,
|
||||
now,null,now,null,"PARTIAL",null,null,"SOURCE_ERROR","secret=credential; customer private data",now,"A",1L,"hash");
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(r));
|
||||
var result=service.runs(q(null,null,null)).records().get(0);
|
||||
assertEquals("SOURCE_ERROR",result.errorCode());assertFalse(result.failureSummary().contains("credential"));
|
||||
}
|
||||
@Test void inventoryFreshnessUsesCompletedObservationAndBarrierBlocksInitialCoverage() {
|
||||
Scope s=new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600,
|
||||
now.minusMonths(2),now,now.minusMonths(2),now,0,90L,now.minusDays(8),now,7,null,null);
|
||||
when(mapper.scopes("primary","A")).thenReturn(List.of(s));
|
||||
when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1,"PUBLISHED","PUBLISH","BUSINESS",
|
||||
now.minusDays(5),now.minusMinutes(5),null,now.minusMinutes(5),now.minusMinutes(5),null,null,"COMPLETE",
|
||||
now.minusMonths(2),now,null,null,now.minusDays(5),"A",1L,"hash")));
|
||||
when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A"));
|
||||
var row=service.overview(q(null,null,null)).warehouses().get(0);
|
||||
assertEquals("PARTIAL",row.initialCoverageStatus());assertEquals("FRESH",row.dataFreshness());
|
||||
assertTrue(row.rebuildRequired());assertEquals("OVERDUE",row.revisionReviewStatus());
|
||||
}
|
||||
@Test void validationBudgetReadsActualFlatPersistedFieldsAndMasksDetails() {
|
||||
Task t=new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","VALIDATION","VALIDATION",
|
||||
"hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,null,null,null,null,
|
||||
"{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now);
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(t);
|
||||
var result=service.taskDetail("LXCOL-TASK",q(null,null,null)).task().validation();
|
||||
assertEquals(10L,result.budgetLimits().get("maxPages"));assertEquals(3L,result.budgetConsumed().get("pages"));
|
||||
assertEquals(5L,result.budgetConsumed().get("http"));assertEquals(1L,result.budgetConsumed().get("distinctDetails"));
|
||||
assertNull(result.budgetConsumed().get("detailHttp"));assertFalse(result.budgetConsumed().containsKey("detailKeys"));
|
||||
assertEquals(Instant.parse("2026-10-12T04:05:00Z"),result.deadlineAt());
|
||||
}
|
||||
@Test void allMapperSqlParsesOfflineAndRunQueryOnlyReadsLightweightTables() {
|
||||
var configuration=new org.apache.ibatis.session.Configuration();
|
||||
configuration.addMapper(CollectionReadMapper.class);
|
||||
var f=new CollectionReadMapper.Filter("primary","A","OUTBOUND","SCHEDULED","FAILED","LXCOL-TASK",now.minusDays(1),now,20,20);
|
||||
var parameters=new HashMap<String,Object>();parameters.put("f",f);parameters.put("b",new Bound(now,99L));
|
||||
parameters.put("account","primary");parameters.put("warehouse","A");parameters.put("taskNo","LXCOL-TASK");
|
||||
parameters.put("ids",List.of(1L,7L));parameters.put("warehouses",List.of("A"));
|
||||
parameters.put("from",LocalDate.of(2026,9,12));parameters.put("until",LocalDate.of(2026,10,12));
|
||||
for(String method:List.of("taskBound","runBound","taskCount","tasks","runCount","runs","tasksByIds","latestAttempts",
|
||||
"publishedRuns","currentTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses",
|
||||
"inventoryDailySamples","inventoryAccumulationStarts")) {
|
||||
String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql();
|
||||
assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot"));
|
||||
assertFalse(sql.contains("ll_selfop_sync_stage"));
|
||||
}
|
||||
String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql();
|
||||
assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?"));
|
||||
String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql();
|
||||
assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?"));
|
||||
assertTrue(samplesSql.contains("r.inventory_daily_sample_date < ?"));
|
||||
assertTrue(samplesSql.contains("r.status='PUBLISHED'"));assertTrue(samplesSql.contains("r.execution_purpose='BUSINESS'"));
|
||||
assertTrue(samplesSql.contains("s.data_family='INVENTORY'"));
|
||||
}
|
||||
@Test void checkpointTimeIsNotInventedFromAttemptFailureTimeAndOffCannotAdvertiseRetry() {
|
||||
Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED",
|
||||
"{\"stageStartedAt\":\"2026-10-12T02:00:00Z\",\"summaryAsOf\":\"2026-10-12T02:30:00Z\"}")));
|
||||
when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,1)));
|
||||
var row=service.runs(q(null,null,null)).records().get(0);
|
||||
assertEquals(Instant.parse("2026-10-12T02:00:00Z"),row.phaseStartedAt());
|
||||
assertEquals(Instant.parse("2026-10-12T02:30:00Z"),row.progressUpdatedAt());assertFalse(row.retryable());
|
||||
}
|
||||
@Test void inventoryFullSnapshotDoesNotWaitForSalesInitialCoverageOrApplyBarrier() {
|
||||
Scope inventory=new Scope(2L,"A","INVENTORY","hash","Asia/Shanghai","0 0 8 * * *",93600,
|
||||
null,null,null,null,0,90L,null,null,7,null,null);
|
||||
when(mapper.scopes("primary","A")).thenReturn(List.of(scope(),inventory));
|
||||
when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-STOCK",1,
|
||||
"PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now.minusMinutes(1),null,now,now,null,null,
|
||||
"COMPLETE",null,null,null,null,now.minusHours(1),"A",2L,"hash")));
|
||||
when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A"));
|
||||
var row=service.overview(q(null,null,null)).warehouses().stream().filter(w->"INVENTORY".equals(w.dataFamily())).findFirst().orElseThrow();
|
||||
assertEquals("COMPLETE",row.initialCoverageStatus());assertFalse(row.rebuildRequired());assertEquals("FRESH",row.dataFreshness());
|
||||
}
|
||||
@Test void anOldScopeHashCannotProveCurrentInitialWindowComplete() {
|
||||
Scope scope=new Scope(1L,"A","OUTBOUND","NEW-HASH","Asia/Shanghai","0 0 8,20 * * *",93600,
|
||||
now.minusMonths(2),now,now.minusMonths(2),now,0,90L,null,null,7,null,null);
|
||||
when(mapper.scopes("primary","A")).thenReturn(List.of(scope));
|
||||
when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1,
|
||||
"PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now,null,now,now,null,null,"COMPLETE",
|
||||
now.minusMonths(2),now,null,null,now.minusHours(1),"A",1L,"OLD-HASH")));
|
||||
assertEquals("PARTIAL",service.overview(q(null,null,null)).warehouses().get(0).initialCoverageStatus());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.request;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
class SelfopEgressControllerTest {
|
||||
@Test void invalidInternalTokenCannotReachAuthority(){
|
||||
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
|
||||
assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
|
||||
assertThatThrownBy(()->c.complete(null,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
@Test void bothCallbacksUseGuardThenAuthority(){var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var r=new SelfopEgressDtos.AuthorizationRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64));c.authorize("synthetic",r);verify(s).authorize(r);var complete=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null);assertThat(c.complete("synthetic",complete).data()).isTrue();verify(s).complete(complete);}
|
||||
@Test void denialUsesExactB0001DataEnvelopeWithInstantOrExplicitNull() throws Exception {
|
||||
var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);
|
||||
doThrow(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,java.time.LocalDateTime.of(2026,10,12,0,0,5))).when(s).authorize(null);
|
||||
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
var json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null)));
|
||||
assertThat(json.path("code").asText()).isEqualTo("B0001");assertThat(json.path("data").path("errorCode").asText()).isEqualTo("ACCOUNT_INTERVAL_WAIT");assertThat(json.path("data").path("waitUntil").asText()).isEqualTo("2026-10-12T00:00:05Z");assertThat(json.path("data").path("sampleDone").booleanValue()).isFalse();
|
||||
doThrow(new EgressRejectedException("VALIDATION_HTTP_LIMIT",true,null)).when(s).authorize(null);json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null)));assertThat(json.path("data").has("waitUntil")).isTrue();assertThat(json.path("data").path("waitUntil").isNull()).isTrue();assertThat(json.path("data").path("sampleDone").booleanValue()).isTrue();
|
||||
}
|
||||
@Test void authorityDtosRoundTripWithIsoInstantAndExplicitNullableOutcome() throws Exception {
|
||||
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var operation=SelfopEgressDtos.Operation.WAREHOUSES;String hash="1".repeat(64);var expiry=java.time.Instant.parse("2026-10-12T00:00:30Z");
|
||||
var permit=new SelfopEgressDtos.AttemptPermit(context,operation,hash,1,expiry,true,true);String permitJson=mapper.writeValueAsString(permit);assertThat(mapper.readTree(permitJson).path("expiresAt").asText()).isEqualTo("2026-10-12T00:00:30Z");assertThat(mapper.readValue(permitJson,SelfopEgressDtos.AttemptPermit.class)).isEqualTo(permit);
|
||||
var request=new SelfopEgressDtos.AuthorizationRequest(context,operation,hash);assertThat(mapper.readValue(mapper.writeValueAsString(request),SelfopEgressDtos.AuthorizationRequest.class)).isEqualTo(request);
|
||||
var complete=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,true,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(complete),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(complete);
|
||||
var nullable=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,null,null,null,false,null);var node=mapper.readTree(mapper.writeValueAsString(nullable));assertThat(node.path("sourceCode").isNull()).isTrue();assertThat(node.path("upstreamStatus").isNull()).isTrue();assertThat(mapper.readValue(node.toString(),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(nullable);
|
||||
var denial=new SelfopEgressDtos.AuthorizationDenial("ACCOUNT_DAILY_BUDGET",false,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(denial),SelfopEgressDtos.AuthorizationDenial.class)).isEqualTo(denial);
|
||||
var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);when(s.authorize(request)).thenReturn(permit);var envelope=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",request)));assertThat(envelope.path("data")).isEqualTo(mapper.readTree(permitJson));assertThat(envelope.path("data").path("budgetConsumed").booleanValue()).isTrue();
|
||||
}
|
||||
@Test void conflictingCompletionPropagatesRejectionInsteadOfSuccessfulAcknowledgment(){
|
||||
var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var completion=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null);
|
||||
doThrow(new EgressRejectedException("EGRESS_COMPLETION_CONFLICT",false,null)).when(s).complete(completion);assertThatThrownBy(()->c.complete("synthetic",completion)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");verify(s).complete(completion);
|
||||
}
|
||||
@Test void durableQueriesUseCurrentReadsAndActualColumnNames()throws Exception{for(String name:new String[]{"lockAccount","lockTask","lockRun"}){var m=java.util.Arrays.stream(SelfopEgressMapper.class.getMethods()).filter(x->x.getName().equals(name)).findFirst().orElseThrow();assertThat(String.join(" ",m.getAnnotation(org.apache.ibatis.annotations.Select.class).value())).contains("FOR UPDATE");}assertThat(SelfopEgressMapper.class.isAnnotationPresent(org.apache.ibatis.annotations.Mapper.class)).isTrue();}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.request;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class SelfopEgressServiceTest {
|
||||
private final SelfopEgressMapper mapper=mock(SelfopEgressMapper.class);
|
||||
private final SelfopAnalyticsProperties cfg=new SelfopAnalyticsProperties();
|
||||
private final MutableClock clock=new MutableClock();
|
||||
private final SelfopOutboundDiscoveryScope account=new SelfopOutboundDiscoveryScope();
|
||||
private final SelfopSyncTask task=new SelfopSyncTask();private final SelfopSyncRun run=new SelfopSyncRun();
|
||||
private final SelfopEgressService service=new SelfopEgressService(mapper,cfg,clock);
|
||||
private static final String INTERNAL="1".repeat(64),PROVIDER="2".repeat(64);
|
||||
SelfopEgressServiceTest(){
|
||||
cfg.setSourceAccount("synthetic");cfg.setCollectionMode("VALIDATION");cfg.setExclusiveGrantId("grant");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2));cfg.setExclusiveHandoffVerified(true);cfg.setPriorEgressClosed(true);cfg.setInflightDrained(true);cfg.setTransferredBudgetVerified(true);cfg.setTransferredBudgetDate(now().plusHours(8).toLocalDate());cfg.setDailyRequestBudget(100);cfg.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());cfg.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());
|
||||
account.setId(10L);account.setSourceAccount("synthetic");account.setActiveTaskId(1L);account.setFenceToken(7L);account.setLeaseUntilUtc(now().plusDays(2));
|
||||
task.setId(1L);task.setDiscoveryScopeId(10L);task.setSourceAccount("synthetic");task.setStatus("RUNNING");task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(1L);task.setValidationBudgetJson(SelfopValidationBudget.freeze(now()));
|
||||
run.setId(2L);run.setTaskId(1L);run.setSourceAccount("synthetic");run.setStatus("FETCHING");run.setExecutionPurpose("VALIDATION");run.setFenceToken(7L);
|
||||
when(mapper.lockAccount("synthetic")).thenReturn(account);when(mapper.lockTask(1L)).thenReturn(task);when(mapper.lockRun(2L)).thenReturn(run);when(mapper.saveAccount(any())).thenReturn(1);when(mapper.saveRoot(any())).thenReturn(1);
|
||||
}
|
||||
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
|
||||
private SelfopRequestContext context(){return new SelfopRequestContext("synthetic",cfg.getCollectionMode(),1L,"selfop-1","grant",1L,2L);}
|
||||
private EgressContext reserve(Operation op,int size,String wh,String key){return service.reserve(context(),op,size,wh,key,INTERNAL);}
|
||||
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
|
||||
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
|
||||
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
|
||||
@Test void offUnknownModeNonProductionBusinessAndUnverifiedHandoffFailBeforeAnyLock(){
|
||||
cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED");
|
||||
cfg.setCollectionMode("BUSINESS");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED");
|
||||
cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();}
|
||||
@Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){
|
||||
var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES);
|
||||
c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY);
|
||||
c=reserve(Operation.OUTBOUND_LOCATE,2,null,null);success(c,Operation.OUTBOUND_LOCATE);
|
||||
var restarted=new SelfopEgressService(mapper,cfg,clock);c=restarted.reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL);
|
||||
restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER));
|
||||
restarted.complete(new CompletionRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,false,null));
|
||||
assertThat(budget().reservedHttp).isEqualTo(4);assertThat(budget().confirmedHttp).isEqualTo(4);assertThat(rootBudget().pages).isEqualTo(3);assertThat(rootBudget().detailHttp).isEqualTo(1);assertThat(rootBudget().detailKeys).containsExactly("C/DO");assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull();
|
||||
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("EGRESS_TASK_NOT_ACTIVE");
|
||||
}
|
||||
@Test void accountSingleFlightHasNoNewCounterWhilePendingAndReplayCannotConsumeTwice(){
|
||||
var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT");assertThat(budget().reservedHttp).isEqualTo(1);
|
||||
service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED");
|
||||
assertThatThrownBy(()->service.complete(new CompletionRequest(c,Operation.WAREHOUSES,INTERNAL,1,null,200,200,false,null))).hasMessage("EGRESS_PERMIT_BINDING_INVALID");
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));assertThat(budget().confirmedHttp).isEqualTo(1);
|
||||
}
|
||||
@Test void preparedOperationCannotBeSubstitutedAndProviderHashIsBoundOnce(){
|
||||
var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID");
|
||||
var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThat(permit.allowedAttempts()).isEqualTo(1);assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(budget().pending.internalRequestHash).isEqualTo(INTERNAL);assertThat(budget().pending.requestHash).isEqualTo(PROVIDER);
|
||||
}
|
||||
@Test void pageLimitEndsSameRootAndDoesNotResetAcrossRestart(){
|
||||
for(int i=0;i<10;i++){var c=reserve(Operation.OUTBOUND_QUERY,1,null,null);success(c,Operation.OUTBOUND_QUERY);}
|
||||
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("VALIDATION_PAGE_LIMIT").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue());
|
||||
assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");assertThat(rootBudget().http).isEqualTo(10);verify(mapper).terminateValidationRuns(eq(1L),eq("SAMPLE_DONE"),eq("VALIDATION_PAGE_LIMIT"),any());
|
||||
}
|
||||
@Test void inventoryTwoPageLimitPerWarehouseSharesGlobalBudget(){
|
||||
success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"B",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY);
|
||||
assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("VALIDATION_INVENTORY_LIMIT");assertThat(rootBudget().inventoryPages).containsEntry("A",2).containsEntry("B",1);assertThat(rootBudget().http).isEqualTo(3);
|
||||
}
|
||||
@Test void repeatedDetailUsesHttpLimitEvenForSameIdentity(){for(int i=0;i<20;i++)success(reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO"),Operation.OUTBOUND_DETAIL);assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_DETAIL_HTTP_LIMIT");assertThat(rootBudget().detailKeys).hasSize(1);assertThat(rootBudget().detailHttp).isEqualTo(20);}
|
||||
@Test void fiveMinuteDeadlineTerminalAndFrozenLimitsCannotIncrease(){clock.advance(300);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("VALIDATION_DEADLINE");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");var b=rootBudget();b.maxHttp=41;assertThatThrownBy(b::validate).hasMessage("VALIDATION_BUDGET_INVALID");}
|
||||
@Test void lostResponseRetainsChargeAndConservativelyCoolsAcrossRestart(){reserve(Operation.WAREHOUSES,1,null,null);clock.advance(121);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN");assertThat(account.getApiCooldownUntilUtc()).isAfter(now());}
|
||||
@Test void rateLimitStopsRootAndAllFamiliesRetainCooldown(){var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER));service.complete(new CompletionRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER,1,"LINGXING_WMS_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300)));assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(300));assertThat(budget().reservedHttp).isEqualTo(1);verify(mapper).terminateValidationRuns(eq(1L),eq("FAILED"),eq("RATE_LIMITED"),any());}
|
||||
@Test void inheritedDailyBudgetIsNotResetByNewGrantOrDate(){cfg.setTransferredDailyHttpCount(99);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);assertThat(budget().reservedHttp).isEqualTo(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");cfg.setExclusiveGrantId("grant2");cfg.setTransferredDailyHttpCount(3);assertThatThrownBy(()->service.reserve(new SelfopRequestContext("synthetic","VALIDATION",1L,"selfop-1","grant2",1L,2L),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().reservedHttp).isEqualTo(100);}
|
||||
@Test void actualStartIntervalUsesAuthorizationTimeAndReturnsQueueDeadline(){var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(4);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT").satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(now().plusSeconds(4)));}
|
||||
@Test void obsoleteExecutionFenceCannotReserveOrConsume(){run.setFenceToken(6L);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST");run.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null);account.setFenceToken(8L);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST");assertThat(budget().reservedHttp).isEqualTo(1);}
|
||||
@Test void totalHttpCapCountsEveryOperationAndPreparedReservation(){var b=rootBudget();b.http=40;task.setValidationBudgetJson(SelfopSupport.json(b));assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_HTTP_LIMIT");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");}
|
||||
@Test void warehouseRequestsShareAccountBudgetAndRejectScopeMismatch(){task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(100L);task.setWarehouseCode("A");run.setScopeId(100L);run.setWarehouseCode("A");when(mapper.warehouseFence(eq(100L),eq(7L),eq(1L),any())).thenReturn(1);var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);success(c,Operation.PRODUCT_INVENTORY);assertThat(budget().reservedHttp).isEqualTo(1);run.setWarehouseCode("B");assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("EGRESS_FENCE_LOST");}
|
||||
@Test void unknownGrantTransferCooldownIsPreservedAndExpiredGrantCannotIssue(){cfg.setTransferredCooldownUntilUtc(now().plusSeconds(100));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");}
|
||||
@Test void persistedDailyCapCannotBeRaisedMiddayToBypassBudget(){cfg.setDailyRequestBudget(1);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);cfg.setDailyRequestBudget(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().dailyLimit).isEqualTo(1);}
|
||||
@Test void oldAttemptCannotConsumeAfterNextPreparedRequest(){var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES);reserve(Operation.OUTBOUND_QUERY,1,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID");}
|
||||
@Test void authorizationsLockAccountBeforeRootAndTask(){var c=reserve(Operation.WAREHOUSES,1,null,null);clearInvocations(mapper);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));var order=inOrder(mapper);order.verify(mapper).lockAccount("synthetic");order.verify(mapper,times(2)).lockTask(1L);order.verify(mapper).lockRun(2L);}
|
||||
@Test void completedPayloadIsIdempotentAcrossRestartAndBusinessDayWithoutChangingJournal(){
|
||||
var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);service.complete(completion);
|
||||
String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();String rootStatus=task.getStatus();
|
||||
clearInvocations(mapper);var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);clock.advance(86400);restarted.complete(completion);
|
||||
assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo(rootStatus);
|
||||
assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completion));verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any());
|
||||
}
|
||||
@Test void everyChangedCompletionOutcomeConflictsWithoutOverwritingDurableResult(){
|
||||
var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
var completed=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,31001,true,clock.instant().plusSeconds(300));service.complete(completed);
|
||||
var changed=List.of(
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,0,completed.errorCode(),429,31001,true,completed.cooldownUntil()),
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"OTHER_ERROR",429,31001,true,completed.cooldownUntil()),
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),200,31001,true,completed.cooldownUntil()),
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,429,true,completed.cooldownUntil()),
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,false,completed.cooldownUntil()),
|
||||
new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,true,completed.cooldownUntil().plusSeconds(1)));
|
||||
String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();var cooldown=account.getApiCooldownUntilUtc();String pause=account.getApiPauseReason();
|
||||
clearInvocations(mapper);
|
||||
for(var candidate:changed){assertThatThrownBy(()->service.complete(candidate)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(cooldown);assertThat(account.getApiPauseReason()).isEqualTo(pause);}
|
||||
assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completed));assertThat(budget().confirmedHttp).isEqualTo(1);verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any());
|
||||
}
|
||||
@Test void provider31001And429AreRateLimitsEvenWithHttp200AndNoPauseFlag(){
|
||||
for(int sourceCode:new int[]{31001,429}){
|
||||
var fixture=new SelfopEgressServiceTest();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,sourceCode,false,null));
|
||||
assertThat(fixture.task.getStatus()).isEqualTo("DEAD");assertThat(fixture.rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(fixture.budget().rateLimitFailures).isEqualTo(1);assertThat(fixture.account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN");
|
||||
}
|
||||
}
|
||||
private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");}
|
||||
@Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
|
||||
String journal=account.getApiBudgetJson();var until=now().plusSeconds(600);account.setApiCooldownUntilUtc(until);
|
||||
service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
|
||||
assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);
|
||||
assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);assertThat(budget().confirmedHttp).isEqualTo(1);
|
||||
clock.advance(601);var restarted=new SelfopEgressService(mapper,cfg,clock);
|
||||
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");
|
||||
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");
|
||||
}
|
||||
@Test void uncertainUncompletedAttemptCannotDowngradeManualPauseWhenItsLeaseExpires(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");clock.advance(121);
|
||||
var restarted=new SelfopEgressService(mapper,cfg,clock);
|
||||
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");
|
||||
assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61);
|
||||
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");
|
||||
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();
|
||||
}
|
||||
@Test void uncertainPauseRequiresExactReservedContextAttemptAndOperation(){
|
||||
var c=reserve(Operation.WAREHOUSES,1,null,null);String journal=account.getApiBudgetJson();clearInvocations(mapper);
|
||||
var wrong=new EgressContext(c.sourceAccount(),c.executionPurpose(),c.rootTaskId(),c.sessionId(),"different-attempt",c.exclusiveGrantId());
|
||||
assertThatThrownBy(()->service.pauseUncertain(wrong,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID");
|
||||
assertThatThrownBy(()->service.pauseUncertain(c,Operation.OUTBOUND_DETAIL,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID");
|
||||
assertThat(account.getApiPauseReason()).isNull();assertThat(account.getApiBudgetJson()).isEqualTo(journal);verify(mapper,never()).saveAccount(any());
|
||||
}
|
||||
@Test void confirmedRatePauseIsNotUpgradedAndStrongerReviewIsPreserved(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300)));
|
||||
String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc();clearInvocations(mapper);
|
||||
service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_HTTP_RATE_LIMITED");
|
||||
assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);verify(mapper,never()).saveAccount(any());
|
||||
account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW");service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");
|
||||
assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);
|
||||
}
|
||||
@Test void rateCompletionCommittedButAckLostRequiresReviewDespitePersistedCooldown(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300)));
|
||||
String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc();
|
||||
service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
|
||||
assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);
|
||||
clock.advance(301);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");
|
||||
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().rateLimitFailures).isEqualTo(1);
|
||||
}
|
||||
@Test void nonRatePauseRequiresReviewAfterCooldownAndRestartWithoutIncreasingRateFailures(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,true,clock.instant().plusSeconds(60)));
|
||||
assertThat(budget().rateLimitFailures).isZero();assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61);
|
||||
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");
|
||||
}
|
||||
@Test void httpAuthorizationFailuresRequireReviewAfterRestart(){
|
||||
for(int status:new int[]{401,403}){var fixture=new SelfopEgressServiceTest();fixture.business();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,status,null,false,null));assertThat(fixture.budget().rateLimitFailures).isZero();assertThat(fixture.account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");fixture.clock.advance(61);assertThatThrownBy(()->new SelfopEgressService(fixture.mapper,fixture.cfg,fixture.clock).reserve(fixture.context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");}
|
||||
}
|
||||
@Test void businessTrueRateLimitRetainsBoundedBackoffAndMayIssueFreshPermitAfterCooldown(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null));
|
||||
assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(60));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");clock.advance(60);
|
||||
var restarted=new SelfopEgressService(mapper,cfg,clock);var next=restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL);assertThat(next.attemptId()).isNotEqualTo(c.attemptId());assertThat(restarted.authorize(new AuthorizationRequest(next,Operation.WAREHOUSES,PROVIDER)).allowedAttempts()).isEqualTo(1);restarted.complete(new CompletionRequest(next,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,false,null));assertThat(budget().rateLimitFailures).isEqualTo(2);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(120));assertThat(budget().reservedHttp).isEqualTo(2);
|
||||
}
|
||||
private static final class MutableClock extends Clock {private Instant instant=Instant.parse("2026-10-12T00:00:00Z");void advance(long seconds){instant=instant.plusSeconds(seconds);}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return instant;}}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.retention;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.InOrder;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SalesRetentionServiceTest {
|
||||
final SalesRetentionMapper mapper=mock(SalesRetentionMapper.class);
|
||||
final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
|
||||
final SalesRetentionService service=new SalesRetentionService(mapper,config);
|
||||
SelfopSyncScope scope; SelfopSyncRun old;
|
||||
@BeforeEach void setup() {
|
||||
scope=new SelfopSyncScope(); scope.setId(1L);scope.setSourceAccount("demo");scope.setWarehouseCode("A");scope.setDataFamily("OUTBOUND");scope.setPublishedRunId(30L);
|
||||
old=new SelfopSyncRun();old.setId(10L);old.setScopeId(1L);old.setSourceAccount("demo");old.setWarehouseCode("A");old.setRunRole("WAREHOUSE_SYNC");
|
||||
old.setStatus("PUBLISHED");old.setExecutionPurpose("BUSINESS");old.setPublishedAtUtc(SelfopSupport.utcNow().minusDays(9));old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));
|
||||
when(mapper.scope(1L)).thenReturn(scope);when(mapper.run(10L)).thenReturn(old);when(mapper.previous(1L,30L)).thenReturn(20L);
|
||||
}
|
||||
@Test void deletionIsOffByDefault() {
|
||||
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void shortRetentionAndOversizedBatchCannotWeakenGuards() {
|
||||
config.setRetentionEnabled(true);config.setSalesRetentionDays(1);config.setPersistenceBatchSize(1000);
|
||||
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
clearInvocations(mapper);
|
||||
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8);
|
||||
assertEquals(new SalesRetentionService.Deleted(10L,500,8),service.deleteBatch(1L,10L));
|
||||
InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L);
|
||||
order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L);
|
||||
order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500);
|
||||
// Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates.
|
||||
verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500);
|
||||
}
|
||||
@Test void currentPreviousActiveInputAndUnpublishedCandidatesAreProtected() {
|
||||
config.setRetentionEnabled(true);
|
||||
scope.setPublishedRunId(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
scope.setPublishedRunId(30L);when(mapper.previous(1L,30L)).thenReturn(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
|
||||
}
|
||||
@Test void pointerAndReferencesRecheckedForEveryBatch() {
|
||||
config.setRetentionEnabled(true);service.deleteBatch(1L,10L);
|
||||
when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);
|
||||
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L);
|
||||
verify(mapper,times(1)).deleteDaily(10L,500);
|
||||
}
|
||||
@Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() {
|
||||
config.setRetentionEnabled(true);
|
||||
scope.setDataFamily("INVENTORY");
|
||||
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
scope.setDataFamily("OUTBOUND");
|
||||
old.setInventoryDailySampleDate(java.time.LocalDate.of(2026,10,1));
|
||||
old.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1");
|
||||
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
|
||||
verify(mapper,never()).deleteDaily(anyLong(),anyInt());
|
||||
verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.statistics;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SalesBucketPolicyTest {
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 10, 10);
|
||||
private static final LocalDate ANCHOR = DATE.plusDays(2);
|
||||
private static final Instant START = DATE.atStartOfDay(ZONE).toInstant();
|
||||
private static final Instant END = DATE.plusDays(1).atStartOfDay(ZONE).toInstant();
|
||||
@Test void actualCompleteDayNeedsNoThirtyDayObservation() {
|
||||
var point = evaluate(DATE, START, END, null, new BigDecimal("2"), true, false, false);
|
||||
assertEquals("COMPLETE", point.dataStatus());
|
||||
assertEquals(new BigDecimal("2"), point.outboundQuantity());
|
||||
assertEquals(START, point.provedFrom());
|
||||
assertEquals(END, point.provedUntil());
|
||||
}
|
||||
@Test void emptyCompleteDayRequiresExistenceAtMidnightForReliableZero() {
|
||||
var zero = evaluate(DATE, START, END, START, null, true, false, false);
|
||||
assertEquals("COMPLETE", zero.dataStatus());
|
||||
assertEquals(BigDecimal.ZERO, zero.outboundQuantity());
|
||||
var partial = evaluate(DATE, START, END, START.plusSeconds(16 * 3600), null, true, false, false);
|
||||
assertEquals("PARTIAL", partial.dataStatus());
|
||||
assertNull(partial.outboundQuantity());
|
||||
assertEquals(BigDecimal.ZERO, partial.observedQuantity());
|
||||
}
|
||||
@Test void firstCollectionMissingMidnightIsPartialEvenWithEarlierExistence() {
|
||||
var point = evaluate(DATE, START.plusSeconds(3600), END, START.minusSeconds(1), null, true, false, false);
|
||||
assertEquals("PARTIAL", point.dataStatus());
|
||||
assertEquals(BigDecimal.ZERO, point.observedQuantity());
|
||||
assertNull(point.outboundQuantity());
|
||||
assertEquals(START.plusSeconds(3600), point.provedFrom());
|
||||
}
|
||||
@Test void laterEvidenceDoesNotCreateZeroBeyondCoveredWatermark() {
|
||||
var point = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(16 * 3600), null, true, false, false);
|
||||
assertEquals("UNKNOWN", point.dataStatus());
|
||||
assertNull(point.observedQuantity());
|
||||
var exactBoundary = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(8 * 3600), null, true, false, false);
|
||||
assertEquals("UNKNOWN", exactBoundary.dataStatus());
|
||||
}
|
||||
@Test void actualRowOutsideContinuousCoverageStillShowsObservedFact() {
|
||||
var point = evaluate(DATE, END, END.plusSeconds(3600), null, new BigDecimal("4"), true, true, true);
|
||||
assertEquals("PARTIAL", point.dataStatus());
|
||||
assertEquals(new BigDecimal("4"), point.observedQuantity());
|
||||
assertNull(point.outboundQuantity());
|
||||
assertNull(point.provedFrom());
|
||||
assertTrue(point.reasonCodes().containsAll(List.of("SALES_REBUILD_REQUIRED", "SALES_STALE")));
|
||||
}
|
||||
@Test void missingEvidenceMissingCoverageAndUnknownUnitRemainUnknown() {
|
||||
assertEquals("UNKNOWN", evaluate(DATE, START, END, null, null, true, false, false).dataStatus());
|
||||
assertEquals("UNKNOWN", evaluate(DATE, null, null, START, null, true, false, false).dataStatus());
|
||||
var unit = evaluate(DATE, START, END, START, BigDecimal.TEN, false, false, false);
|
||||
assertNull(unit.observedQuantity());
|
||||
assertTrue(unit.reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT"));
|
||||
var unavailableVersion = SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(DATE, ANCHOR, ZONE,
|
||||
DATE, ANCHOR, START, END, START, BigDecimal.TEN, 1L, true, false, false, false));
|
||||
assertEquals("UNKNOWN", unavailableVersion.dataStatus());
|
||||
assertNull(unavailableVersion.observedQuantity());
|
||||
}
|
||||
@Test void todayNeverCompleteAndThreeDayAggregatePreservesCollectedFive() {
|
||||
var first = evaluate(DATE, START, END, START, new BigDecimal("2"), true, false, false);
|
||||
var second = evaluate(DATE.plusDays(1), START, END.plusSeconds(86400), START, null, true, false, false);
|
||||
var today = evaluate(ANCHOR, START, END.plusSeconds(2 * 86400), START, new BigDecimal("3"), true, false, false);
|
||||
assertEquals("PARTIAL", today.dataStatus());
|
||||
assertTrue(today.reasonCodes().contains("TODAY_INCOMPLETE"));
|
||||
var summary = SalesBucketPolicy.aggregate(List.of(first, second, today));
|
||||
assertNull(summary.outboundQuantity());
|
||||
assertEquals(new BigDecimal("5"), summary.observedQuantity());
|
||||
assertEquals(2, summary.completeDays());
|
||||
assertEquals(1, summary.partialDays());
|
||||
assertEquals("PARTIAL", summary.dataStatus());
|
||||
}
|
||||
@Test void fullyUnknownAndEmptyRangesDoNotSumToZeroWhileCompleteZerosDo() {
|
||||
var unknown = evaluate(DATE, null, null, null, null, true, false, false);
|
||||
var summary = SalesBucketPolicy.aggregate(List.of(unknown));
|
||||
assertEquals("UNKNOWN", summary.dataStatus());
|
||||
assertNull(summary.observedQuantity());
|
||||
assertNull(SalesBucketPolicy.aggregate(List.of()).observedQuantity());
|
||||
var zero = evaluate(DATE, START, END, START, null, true, false, false);
|
||||
assertEquals(BigDecimal.ZERO, SalesBucketPolicy.aggregate(List.of(zero)).outboundQuantity());
|
||||
}
|
||||
private SalesBucketPolicy.Point evaluate(LocalDate date, Instant from, Instant until, Instant evidence,
|
||||
BigDecimal quantity, boolean unit, boolean rebuild, boolean stale) {
|
||||
return SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(date, ANCHOR, ZONE, DATE,
|
||||
ANCHOR.plusDays(1), from, until, evidence, quantity, quantity == null ? null : 1L,
|
||||
unit, true, rebuild, stale));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.statistics;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SalesRangePolicyTest {
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final Instant AS_OF = Instant.parse("2026-10-12T02:00:00Z");
|
||||
@Test void fiveNaturalPresetsKeepDailyPoints() {
|
||||
String[] presets = {"TODAY", "LAST_3_DAYS", "THIS_WEEK", "LAST_15_DAYS", "THIS_MONTH"};
|
||||
String[] starts = {"2026-10-12", "2026-10-10", "2026-10-12", "2026-09-28", "2026-10-01"};
|
||||
int[] counts = {1, 3, 1, 15, 12};
|
||||
for (int i = 0; i < presets.length; i++) {
|
||||
var range = SalesRangePolicy.resolve(presets[i], null, null, "DAY", AS_OF, ZONE, 366);
|
||||
assertEquals(LocalDate.parse(starts[i]), range.startDate());
|
||||
assertEquals(counts[i], range.dayCount());
|
||||
assertEquals(LocalDate.of(2026, 10, 13), range.endDateExclusive());
|
||||
assertEquals(Instant.parse("2026-10-12T16:00:00Z"), range.endAtExclusive());
|
||||
}
|
||||
}
|
||||
@Test void weekMayCrossYearAndAnchorUsesExplicitZone() {
|
||||
var range = SalesRangePolicy.resolve("THIS_WEEK", null, null, "DAY",
|
||||
Instant.parse("2025-12-31T17:00:00Z"), ZONE, 366);
|
||||
assertEquals(LocalDate.of(2026, 1, 1), range.anchorDate());
|
||||
assertEquals(LocalDate.of(2025, 12, 29), range.startDate());
|
||||
assertEquals(4, range.dayCount());
|
||||
}
|
||||
@Test void customIncludesLeapDayAndHasHardMaximum() {
|
||||
var asOf = Instant.parse("2024-12-31T04:00:00Z");
|
||||
var range = SalesRangePolicy.resolve("CUSTOM", LocalDate.of(2024, 1, 1),
|
||||
LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366);
|
||||
assertEquals(366, range.dayCount());
|
||||
assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("CUSTOM",
|
||||
LocalDate.of(2023, 12, 31), LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366));
|
||||
}
|
||||
@Test void explicitTestZoneUsesCalendarDaysAcrossDaylightSavingChange() {
|
||||
var range = SalesRangePolicy.resolve("TODAY", null, null, "DAY",
|
||||
Instant.parse("2026-03-08T12:00:00Z"), ZoneId.of("America/New_York"), 366);
|
||||
assertEquals(1, range.dayCount());
|
||||
assertEquals(23 * 3600L, java.time.Duration.between(range.startAt(), range.endAtExclusive()).getSeconds());
|
||||
}
|
||||
@Test void conflictsUnknownEnumsFutureReversedAndMissingDatesAreRejected() {
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("WEEK", null, null, "DAY"));
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", null, null, "WEEK"));
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", LocalDate.of(2026, 10, 1), null, "DAY"));
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", null, null, "DAY"));
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 13), "DAY"));
|
||||
assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 11), "DAY"));
|
||||
assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("LAST_3_DAYS", null, null, "DAY", AS_OF, ZONE, 2));
|
||||
}
|
||||
private SalesRangePolicy.Range resolve(String preset, LocalDate start, LocalDate end, String bucket) {
|
||||
return SalesRangePolicy.resolve(preset, start, end, bucket, AS_OF, ZONE, 366);
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.time.LocalDateTime;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.read;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopCommandServiceTest {
|
||||
private final SelfopTaskService taskService = mock(SelfopTaskService.class);
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final SelfopCommandService service = new SelfopCommandService(taskService, scopes, accounts, config, tasks);
|
||||
|
||||
private void configure() {
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account");
|
||||
config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse());
|
||||
}
|
||||
|
||||
@Test void retryCannotResumeTaskBelongingToAnotherWarehouse() {
|
||||
configure();
|
||||
var original = original("synthetic-account", "WH-B");
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SelfopSyncTask> q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; });
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH");
|
||||
verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString());
|
||||
verify(taskService, never()).create(any(SelfopSyncTask.class));
|
||||
assertThat(original.getStatus()).isEqualTo("DEAD");
|
||||
}
|
||||
|
||||
@Test void retryCannotResumeTaskBelongingToAnotherSourceAccount() {
|
||||
configure();
|
||||
var original = original("different-source-account", "WH-A");
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SelfopSyncTask> q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; });
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH");
|
||||
verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString());
|
||||
verify(taskService, never()).create(any(SelfopSyncTask.class));
|
||||
assertThat(original.getStatus()).isEqualTo("DEAD");
|
||||
}
|
||||
|
||||
@Test void unknownSourceTimezoneAllowsRawBackfillTaskWithoutClaimingUtcCoverage() {
|
||||
configure();
|
||||
var scope = new SelfopSyncScope(); scope.setId(10L); scope.setScopeHash("synthetic-scope-hash");
|
||||
var account = new SelfopOutboundDiscoveryScope(); account.setId(20L);
|
||||
when(scopes.selectOne(any())).thenReturn(scope);
|
||||
when(accounts.selectOne(any())).thenReturn(account);
|
||||
when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call -> call.getArgument(0));
|
||||
var from = LocalDateTime.of(2026, 10, 11, 8, 0);
|
||||
var until = from.plusHours(12);
|
||||
var command = new SelfopCommandService.Command("raw-slice", "WH-A", "BACKFILL", null, from, until, null, null, "offline sample");
|
||||
var task = service.request(command, "42");
|
||||
assertThat(config.getSourceQueryTimezone()).isNull();
|
||||
assertThat(config.isExecutionEnabled()).isTrue();
|
||||
assertThat(task.getQueryFromUtc()).isNull();
|
||||
assertThat(task.getQueryUntilUtc()).isNull();
|
||||
assertThat(task.getTaskRole()).isEqualTo("ACCOUNT_DISCOVERY");
|
||||
assertThat(task.getDiscoveryScopeId()).isEqualTo(20L);
|
||||
var query = read(task.getQueryScopeJson(), SelfopDiscoveryService.Query.class);
|
||||
assertThat(query.startTime()).isEqualTo(from.toString());
|
||||
assertThat(query.endTime()).isEqualTo(until.toString());
|
||||
var captured = ArgumentCaptor.forClass(SelfopSyncTask.class);
|
||||
verify(taskService).create(captured.capture());
|
||||
assertThat(captured.getValue()).isSameAs(task);
|
||||
verifyNoInteractions(tasks);
|
||||
}
|
||||
|
||||
private SelfopSyncTask original(String sourceAccount, String warehouse) {
|
||||
var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(100L); task.setSourceAccount(sourceAccount);
|
||||
task.setWarehouseCode(warehouse); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("DEAD");
|
||||
return task;
|
||||
}
|
||||
@Test void offModeAndNonProductionBusinessDoNotAcceptNewCollectionTask() {
|
||||
configure();config.setCollectionMode("OFF");
|
||||
assertThat(config.isExecutionEnabled()).isFalse();
|
||||
var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_PURPOSE_INVALID");
|
||||
config.setCollectionMode("BUSINESS");config.setEnvironment("test");
|
||||
assertThat(config.isExecutionEnabled()).isFalse();
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_BUSINESS_ENVIRONMENT_REJECTED");
|
||||
verify(taskService,never()).create(any(SelfopSyncTask.class));
|
||||
}
|
||||
@Test void validationModeRejectsFormalInventoryEntry() {
|
||||
configure();config.setCollectionMode("VALIDATION");
|
||||
var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_VALIDATION_ENTRY_REQUIRED");
|
||||
verify(taskService,never()).create(any(SelfopSyncTask.class));
|
||||
}
|
||||
private SelfopCommandService.Command retry() {
|
||||
return new SelfopCommandService.Command("retry-key", "WH-A", "RETRY", "LXCOL-20261012-00000001", null, null, null, null, "offline retry");
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopDiscoveryServiceTest {
|
||||
private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class);
|
||||
private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class);
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopTaskService taskService = mock(SelfopTaskService.class);
|
||||
private final SelfopLeaseService leases = mock(SelfopLeaseService.class);
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final TransactionTemplate tx = mock(TransactionTemplate.class);
|
||||
private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(30L, "account-worker", 4L, true);
|
||||
private final Map<Long, SelfopSyncStage> stored = new LinkedHashMap<>();
|
||||
private final Map<String, SelfopSyncTask> children = new LinkedHashMap<>();
|
||||
private final LocalDateTime from = LocalDateTime.of(2026, 10, 11, 0, 0);
|
||||
private final LocalDateTime until = from.plusDays(1);
|
||||
private SelfopDiscoveryService service;
|
||||
private SelfopSyncTask task;
|
||||
private SelfopSyncRun run;
|
||||
private SelfopSyncScope scope;
|
||||
private SelfopOutboundDiscoveryScope account;
|
||||
|
||||
@BeforeEach void prepare() {
|
||||
doAnswer(inv -> { Consumer<TransactionStatus> cb = inv.getArgument(0); cb.accept(mock(TransactionStatus.class)); return null; })
|
||||
.when(tx).executeWithoutResult(any());
|
||||
account = new SelfopOutboundDiscoveryScope(); account.setId(30L); account.setRequestBudget(10); account.setTimeBudgetSeconds(60);
|
||||
when(accounts.selectById(30L)).thenReturn(account);
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setSourceAccount("demo"); task.setDiscoveryScopeId(30L);
|
||||
task.setDiscoverySequence(17L); task.setObservationBaseline(90L); task.setTaskType("SCHEDULED"); task.setScopeHash("account-hash");
|
||||
task.setQueryFromUtc(from); task.setQueryUntilUtc(until); task.setStatus("RUNNING");
|
||||
task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from.toString(), until.toString(), null, null, null)));
|
||||
run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(20L); run.setStatus("FETCHING");
|
||||
scope = new SelfopSyncScope(); scope.setId(10L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A");
|
||||
scope.setDataFamily("OUTBOUND"); scope.setScopeHash("warehouse-hash"); scope.setScopeJson("{}"); scope.setSourceTimezone("UTC");
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setSourceQueryTimezone("UTC"); config.setQuantityUnit("PCS");
|
||||
when(scopes.selectList(any())).thenReturn(List.of(scope));
|
||||
when(leases.nextObservation(lease)).thenReturn(101L, 102L, 103L);
|
||||
when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(inv -> {
|
||||
SelfopSyncTask child = inv.getArgument(0);
|
||||
child.setId(100L + children.size());
|
||||
return children.computeIfAbsent(child.getRequestKey(), ignored -> child);
|
||||
});
|
||||
doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; })
|
||||
.when(stages).insert(any(SelfopSyncStage.class));
|
||||
doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; })
|
||||
.when(stages).updateById(any(SelfopSyncStage.class));
|
||||
when(stages.selectById(anyLong())).thenAnswer(inv -> stored.get((Long) inv.getArgument(0)));
|
||||
when(stages.selectOne(any())).thenAnswer(inv -> {
|
||||
QueryWrapper<SelfopSyncStage> query = inv.getArgument(0); String sql = query.getSqlSegment();
|
||||
if (sql.contains("observation_sequence")) return null;
|
||||
var values = query.getParamNameValuePairs().values();
|
||||
return stored.values().stream().filter(s -> values.contains(s.getStageBatchId()) && values.contains(s.getObjectKeyHash())).findFirst().orElse(null);
|
||||
});
|
||||
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString())).thenAnswer(inv -> detail(inv.getArgument(3), "2026-10-11 12:00:00"));
|
||||
service = new SelfopDiscoveryService(client, tasks, runs, stages, orders, scopes, accounts, taskService, leases, config, tx);
|
||||
}
|
||||
private LingxingAnalyticsClient.Summary summary(String no, String time) {
|
||||
return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time);
|
||||
}
|
||||
private SelfopPayloads.Order detail(String no, String time) {
|
||||
return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time,
|
||||
List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS")));
|
||||
}
|
||||
private LingxingAnalyticsClient.Page<LingxingAnalyticsClient.Summary> page(int current, int size, long total, int pages,
|
||||
LingxingAnalyticsClient.Summary... rows) { return new LingxingAnalyticsClient.Page<>(List.of(rows), current, size, total, pages); }
|
||||
private void directoryTwoOrders() {
|
||||
when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 2, 1,
|
||||
summary("one", "2026-10-11 12:00:00"), summary("two", "2026-10-11 12:00:00")));
|
||||
}
|
||||
|
||||
@Test void paginatedLocateYieldsDurableCursorThenResumesWithoutFiniteFailure() {
|
||||
config.setPageSize(1); account.setRequestBudget(1);
|
||||
task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null, null, "target", "customer", null)));
|
||||
when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1))).thenReturn(page(1, 1, 2, 2, summary("target", "2026-10-11 12:00:00")));
|
||||
when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1))).thenReturn(page(2, 1, 2, 2, summary("other", "2026-10-11 12:00:00")));
|
||||
service.collect(task, run, lease);
|
||||
var saved = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class);
|
||||
assertEquals(2, saved.getNextPage()); assertEquals(1, saved.getObjects().size()); assertFalse(saved.isDirectoryComplete());
|
||||
verify(taskService).yield(1L, 20L, lease); verify(client, never()).detail(any(SelfopRequestContext.class), anyString(), anyString(), anyString());
|
||||
account.setRequestBudget(10); service.collect(task, run, lease);
|
||||
verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1)); verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1));
|
||||
assertEquals("ROUTED", run.getStatus());
|
||||
verify(taskService, never()).fail(anyLong(), anyLong(), anyString(), anyBoolean(), any());
|
||||
}
|
||||
@Test void stableDirectoryScansYieldAndResumeBeforeDetailsWithoutRepeatingFirstScan() {
|
||||
directoryTwoOrders(); account.setRequestBudget(1);
|
||||
service.collect(task, run, lease);
|
||||
var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class);
|
||||
assertEquals(2, cursor.getScan()); assertEquals(1, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size());
|
||||
assertEquals(2, stored.size()); assertTrue(children.isEmpty());
|
||||
account.setRequestBudget(10); service.collect(task, run, lease);
|
||||
verify(client, times(2)).discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2));
|
||||
verify(client, times(2)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString());
|
||||
assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus())));
|
||||
assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount());
|
||||
}
|
||||
@Test void eachActualDetailGetsIndependentObservationNotTaskSequence() {
|
||||
directoryTwoOrders(); service.collect(task, run, lease);
|
||||
assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList()));
|
||||
assertTrue(stored.values().stream().allMatch(s -> s.getStageBatchId().equals(1L) && !s.getObservationSequence().equals(task.getDiscoverySequence())));
|
||||
verify(leases, times(2)).nextObservation(lease);
|
||||
assertEquals(17L, children.values().iterator().next().getDiscoverySequence());
|
||||
var contexts=org.mockito.ArgumentCaptor.forClass(SelfopRequestContext.class);
|
||||
verify(client,times(2)).detail(contexts.capture(),eq("A"),eq("customer"),anyString());
|
||||
assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose())
|
||||
&&Objects.equals(c.taskId(),1L)&&Objects.equals(c.runId(),20L)));
|
||||
}
|
||||
@Test void unknownWarehouseSourceTimezoneKeepsChildWaitingAndNoSalesCoverage() {
|
||||
directoryTwoOrders(); scope.setSourceTimezone(null); service.collect(task, run, lease);
|
||||
var child = children.values().iterator().next(); assertEquals("WAITING_SOURCE", child.getStatus());
|
||||
assertEquals(2L, read(child.getRoutingManifestJson(), SelfopRoutingManifest.class).getTotal());
|
||||
assertNull(scope.getDiscoveryFromUtc()); assertNull(scope.getDiscoveryUntilUtc());
|
||||
assertNull(scope.getPublishedRunId()); verify(taskService, never()).begin(anyLong(), any());
|
||||
}
|
||||
@Test void knownExclusiveEndpointRemainsAccountEvidenceButLeavesWarehouseSlice() {
|
||||
when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, summary("end", "2026-10-12 00:00:00")));
|
||||
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("end"))).thenReturn(detail("end", "2026-10-12 00:00:00"));
|
||||
service.collect(task, run, lease);
|
||||
assertEquals(1L, run.getSourceTotalCount()); assertEquals(1L, run.getUniqueObjectCount());
|
||||
var child = children.values().iterator().next(); var routed = read(child.getRoutingManifestJson(), SelfopRoutingManifest.class);
|
||||
assertEquals(0L, routed.getTotal()); assertTrue(routed.getObjects().isEmpty()); assertEquals("READY", child.getStatus());
|
||||
assertEquals(1, stored.size()); assertEquals("VALID", stored.values().iterator().next().getValidationStatus());
|
||||
}
|
||||
|
||||
@Test void relocationBudgetYieldPersistsRefreshAndCurrentWarehouseThenResumesWithoutLocatingAgain() {
|
||||
String key = completedParent("moved"); account.setRequestBudget(2);
|
||||
when(tasks.selectById(anyLong())).thenAnswer(inv -> {
|
||||
Long id = inv.getArgument(0);
|
||||
return id.equals(task.getId()) ? task : children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElse(null);
|
||||
});
|
||||
Map<Long, SelfopSyncRun> refreshRuns = new HashMap<>();
|
||||
when(taskService.begin(anyLong(), eq(lease))).thenAnswer(inv -> {
|
||||
Long id = inv.getArgument(0);
|
||||
return refreshRuns.computeIfAbsent(id, ignored -> {
|
||||
var attempt = new SelfopSyncRun(); attempt.setExecutionPurpose("BUSINESS"); attempt.setSourceAccount("demo"); attempt.setId(1000L + id); attempt.setTaskId(id);
|
||||
attempt.setAttemptNo(1); attempt.setFenceToken(lease.fence()); attempt.setStatus("FETCHING"); return attempt;
|
||||
});
|
||||
});
|
||||
doAnswer(inv -> {
|
||||
Long id = inv.getArgument(0); SelfopSyncTask yielded = id.equals(task.getId()) ? task
|
||||
: children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow();
|
||||
yielded.setStatus("RETRY_WAIT"); return null;
|
||||
}).when(taskService).yield(anyLong(), anyLong(), eq(lease));
|
||||
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved")))
|
||||
.thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false));
|
||||
var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00");
|
||||
when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved));
|
||||
when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null,
|
||||
100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS"))));
|
||||
|
||||
service.collect(task, run, lease);
|
||||
var parentRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key);
|
||||
assertEquals("B", parentRef.getWarehouseCode()); assertNotNull(parentRef.getRefreshTaskId()); assertNull(parentRef.getEffectiveStageId());
|
||||
var refresh = children.values().stream().filter(t -> parentRef.getRefreshTaskId().equals(t.getId())).findFirst().orElseThrow();
|
||||
assertEquals("RECHECK", refresh.getTaskType()); assertEquals("RETRY_WAIT", refresh.getStatus());
|
||||
assertTrue(read(refresh.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete());
|
||||
verify(client, never()).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"));
|
||||
|
||||
account.setRequestBudget(10); service.collect(task, run, lease);
|
||||
var finalRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key);
|
||||
assertEquals(parentRef.getRefreshTaskId(), finalRef.getRefreshTaskId()); assertNotNull(finalRef.getEffectiveStageId());
|
||||
assertEquals("B", finalRef.getWarehouseCode()); assertEquals("SUCCEEDED", refresh.getStatus()); assertEquals("ROUTED", run.getStatus());
|
||||
var evidence = stored.get(finalRef.getEffectiveStageId()); assertEquals(102L, evidence.getObservationSequence());
|
||||
assertEquals(refresh.getId(), evidence.getStageBatchId());
|
||||
verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2));
|
||||
verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved"));
|
||||
verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"));
|
||||
}
|
||||
|
||||
@Test void authenticationAndSignatureFailuresNeverFallbackToOrderRelocation() {
|
||||
completedParent("protected");
|
||||
for (String code : List.of("LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED")) {
|
||||
doThrow(new AnalyticsClientException(code, false)).when(client).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("protected"));
|
||||
assertEquals(code, assertThrows(AnalyticsClientException.class, () -> service.collect(task, run, lease)).code());
|
||||
}
|
||||
verify(client, never()).locate(any(SelfopRequestContext.class), anyString(), anyInt(), anyInt());
|
||||
assertTrue(children.values().stream().noneMatch(t -> "RECHECK".equals(t.getTaskType())));
|
||||
}
|
||||
|
||||
private String completedParent(String deliveryNo) {
|
||||
var manifest = new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); manifest.setPages(1); manifest.setTotal(1L);
|
||||
var entry = new SelfopRoutingManifest.Entry(); entry.setCustomerCode("customer"); entry.setDeliveryNo(deliveryNo); entry.setWarehouseCode("A");
|
||||
String key = hash(List.of("demo", "customer", deliveryNo)); manifest.getObjects().put(key, entry);
|
||||
task.setRoutingManifestJson(json(manifest)); return key;
|
||||
}
|
||||
@Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() {
|
||||
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage());
|
||||
assertNull(account.getCatalogUntilUtc());assertNull(scope.getDiscoveryUntilUtc());assertTrue(children.isEmpty());
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.outbound.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopExecutionServiceTest {
|
||||
@Test void unexpectedValidationFailureTerminatesSessionInsteadOfSchedulingRetry() {
|
||||
executeValidationFailure(new IllegalStateException("VALIDATION_STRUCTURE_REJECTED"),"VALIDATION_STRUCTURE_REJECTED");
|
||||
}
|
||||
@Test void escapingValidationWaitCannotUseBusinessYieldPath() {
|
||||
executeValidationFailure(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,LocalDateTime.now().plusMinutes(1)),
|
||||
"ACCOUNT_INTERVAL_WAIT");
|
||||
}
|
||||
private void executeValidationFailure(RuntimeException failure,String code) {
|
||||
var config=new SelfopAnalyticsProperties();config.setCollectionMode("VALIDATION");
|
||||
var taskService=mock(SelfopTaskService.class);var leases=mock(SelfopLeaseService.class);
|
||||
var validation=mock(SelfopValidationRunner.class);var tasks=mock(SelfopSyncTaskMapper.class);
|
||||
var discovery=mock(SelfopDiscoveryService.class);var stock=mock(SelfopInventoryCollector.class);
|
||||
var snapshots=mock(InventorySnapshotService.class);var facts=mock(OutboundFactService.class);
|
||||
var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);
|
||||
var recovery=mock(SelfopReferenceRecovery.class);
|
||||
var task=new SelfopSyncTask();task.setId(1L);task.setTaskRole("ACCOUNT_DISCOVERY");
|
||||
task.setDiscoveryScopeId(3L);task.setExecutionPurpose("VALIDATION");task.setStatus("READY");task.setValidationRootTaskId(1L);
|
||||
var run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setExecutionPurpose("VALIDATION");
|
||||
var lease=new SelfopLeaseService.Lease(3L,"owner",8L,true);
|
||||
when(tasks.selectById(1L)).thenReturn(task);when(leases.acquireDiscovery(eq(3L),anyString())).thenReturn(lease);
|
||||
when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease);
|
||||
var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher,
|
||||
mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),
|
||||
recovery,validation,mock(SelfopSyncRunMapper.class));
|
||||
service.execute(1L);
|
||||
verify(validation).fail(task,run,lease,code);verify(leases).release(lease);
|
||||
verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any());
|
||||
verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());
|
||||
verifyNoInteractions(discovery,stock,snapshots,facts,candidates,publisher,recovery);
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Fully offline source pages; the transaction callback fake verifies orchestration, not DB rollback. */
|
||||
class SelfopInventoryCollectorTest {
|
||||
private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class);
|
||||
private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopLeaseService leases = mock(SelfopLeaseService.class);
|
||||
private final SelfopTaskService taskService = mock(SelfopTaskService.class);
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final TransactionTemplate tx = mock(TransactionTemplate.class);
|
||||
private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(1L, "owner", 4L, false);
|
||||
private SelfopInventoryCollector collector;
|
||||
private SelfopSyncTask task;
|
||||
private SelfopSyncRun run;
|
||||
|
||||
@BeforeEach void prepare() {
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0);
|
||||
callback.accept(mock(TransactionStatus.class)); return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60);
|
||||
collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx);
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(20L); task.setSourceAccount("demo"); task.setWarehouseCode("A");
|
||||
run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING");
|
||||
run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow());
|
||||
}
|
||||
private SelfopPayloads.Stock stock(String sku, BigDecimal available) {
|
||||
return new SelfopPayloads.Stock("demo", "customer", "A", sku, 0, "Name",
|
||||
BigDecimal.TEN, available, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
}
|
||||
private LingxingAnalyticsClient.Page<SelfopPayloads.Stock> page(int current, long total, int pages, SelfopPayloads.Stock... rows) {
|
||||
return new LingxingAnalyticsClient.Page<>(List.of(rows), current, 2, total, pages);
|
||||
}
|
||||
private void stableThreeRows() {
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO)));
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("c", null)));
|
||||
}
|
||||
|
||||
@Test void twoIdenticalScansValidateOneAttemptAndOnlyFirstScanWritesEvidence() {
|
||||
stableThreeRows();
|
||||
assertTrue(collector.collect(task, run, lease));
|
||||
assertEquals("VALIDATED", run.getStatus()); assertEquals("COMPLETE", run.getCoverageStatus());
|
||||
assertEquals(3L, run.getSourceTotalCount()); assertEquals(3L, run.getUniqueObjectCount()); assertEquals(2, run.getPageCount());
|
||||
verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2));
|
||||
var capture = ArgumentCaptor.forClass(SelfopSyncStage.class);
|
||||
verify(stages, times(3)).insert(capture.capture());
|
||||
assertTrue(capture.getAllValues().stream().allMatch(s -> s.getRunId() == 10L && s.getStageBatchId() == 10L && s.getLastFetchRunId() == 10L));
|
||||
assertNotNull(run.getFetchFinishedAtUtc());
|
||||
var contexts=ArgumentCaptor.forClass(SelfopRequestContext.class);
|
||||
verify(client,times(4)).inventory(contexts.capture(),eq("A"),anyInt(),eq(2));
|
||||
assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose())
|
||||
&&Objects.equals(c.taskId(),20L)&&Objects.equals(c.runId(),10L)&&Objects.equals(c.rootTaskId(),20L)));
|
||||
}
|
||||
@Test void duplicateKeyRejectsReportedTotalEvenIfNumberOfReturnedRowsMatches() {
|
||||
// Scaled version of the observed 5741 rows / 5740 unique dimensions incident.
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO)));
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("a", BigDecimal.ONE)));
|
||||
var error = assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease));
|
||||
assertEquals("SELFOP_STOCK_DUPLICATE", error.getMessage());
|
||||
assertIncompleteAttemptWithPersistedProgress();
|
||||
}
|
||||
@Test void sourcePageFailureCannotMarkAttemptComplete() {
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO)));
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenThrow(new IllegalStateException("SOURCE_TIMEOUT"));
|
||||
assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease));
|
||||
assertFalse(read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete());
|
||||
assertIncompleteAttemptWithPersistedProgress();
|
||||
}
|
||||
@Test void leakedWarehouseIsRejectedWithoutCompletingRun() {
|
||||
var leaked = new SelfopPayloads.Stock("demo", "customer", "B", "sku", 0, null,
|
||||
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, leaked));
|
||||
assertEquals("SELFOP_STOCK_SCOPE_LEAK", assertThrows(IllegalStateException.class,
|
||||
() -> collector.collect(task, run, lease)).getMessage());
|
||||
verify(stages, never()).insert(any(SelfopSyncStage.class)); verify(runs, never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
@Test void unknownAndExplicitZeroRemainDifferentInDurablePayloads() {
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 2, 1, stock("zero", BigDecimal.ZERO), stock("unknown", null)));
|
||||
assertTrue(collector.collect(task, run, lease));
|
||||
var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(2)).insert(capture.capture());
|
||||
var first = read(capture.getAllValues().get(0).getPayloadJson(), SelfopPayloads.Stock.class);
|
||||
var second = read(capture.getAllValues().get(1).getPayloadJson(), SelfopPayloads.Stock.class);
|
||||
assertEquals(BigDecimal.ZERO, first.availableQuantity()); assertNull(second.availableQuantity());
|
||||
}
|
||||
@Test void budgetYieldPersistsNextPageAndResumeUsesOriginalAttempt() {
|
||||
stableThreeRows(); config.setRequestBudget(1);
|
||||
assertFalse(collector.collect(task, run, lease));
|
||||
var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class);
|
||||
assertEquals(1, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size());
|
||||
verify(taskService).yield(20L, 10L, lease);
|
||||
// Simulate restart by constructing a fresh collector and reading only the persisted task cursor.
|
||||
var restarted = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx);
|
||||
config.setRequestBudget(10);
|
||||
assertTrue(restarted.collect(task, run, lease));
|
||||
var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(3)).insert(capture.capture());
|
||||
assertTrue(capture.getAllValues().stream().allMatch(s -> Objects.equals(s.getStageBatchId(), 10L)));
|
||||
verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2));
|
||||
assertEquals("COMPLETE", run.getCoverageStatus());
|
||||
}
|
||||
@Test void secondScanCanYieldAndResumeWithoutDuplicatingFirstScanStages() {
|
||||
stableThreeRows(); config.setRequestBudget(3);
|
||||
assertFalse(collector.collect(task, run, lease));
|
||||
var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class);
|
||||
assertEquals(2, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getObjects().size());
|
||||
config.setRequestBudget(10); assertTrue(collector.collect(task, run, lease));
|
||||
verify(stages, times(3)).insert(any(SelfopSyncStage.class));
|
||||
verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2));
|
||||
}
|
||||
@Test void secondScanChangedQuantityCannotPassByKeepingSameIdentitySet() {
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, stock("a", BigDecimal.ONE)),
|
||||
page(1, 1, 1, stock("a", BigDecimal.TEN)));
|
||||
assertEquals("SELFOP_STOCK_SCAN_UNSTABLE", assertThrows(IllegalStateException.class,
|
||||
() -> collector.collect(task, run, lease)).getMessage());
|
||||
assertIncompleteAttemptWithPersistedProgress();
|
||||
}
|
||||
@Test void validationPurposeCannotReachFormalCollector() {
|
||||
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
|
||||
verifyNoInteractions(client,stages,tasks,runs);
|
||||
}
|
||||
private void assertIncompleteAttemptWithPersistedProgress() {
|
||||
assertEquals("FETCHING",run.getStatus());
|
||||
assertNull(run.getFetchFinishedAtUtc());
|
||||
assertNull(run.getCoverageStatus());
|
||||
assertNull(run.getSourceTotalCount());
|
||||
assertNull(run.getUniqueObjectCount());
|
||||
assertNotNull(run.getHeartbeatAtUtc());
|
||||
verify(runs,times(1)).updateById(argThat((SelfopSyncRun saved)->
|
||||
"FETCHING".equals(saved.getStatus()) && saved.getFetchFinishedAtUtc()==null
|
||||
&& saved.getCoverageStatus()==null && saved.getHeartbeatAtUtc()!=null));
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopInventoryRecoveryTest {
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class);
|
||||
private final SelfopLeaseService leases = mock(SelfopLeaseService.class);
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "worker", 7L, false);
|
||||
private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases);
|
||||
private SelfopSyncTask task;
|
||||
private SelfopSyncRun original;
|
||||
private SelfopSyncScope inventory;
|
||||
|
||||
@BeforeEach void prepare() {
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setScopeId(10L); task.setSourceAccount("demo");
|
||||
task.setWarehouseCode("A"); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("RUNNING");
|
||||
task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(1); task.setScopeHash("inventory-hash"); task.setQueryScopeJson("{}");
|
||||
var manifest = new SelfopRoutingManifest(); manifest.setNextPage(2); manifest.setTotal(3L); manifest.setPages(2);
|
||||
manifest.getFirstScan().put("old-key", "old-value"); task.setRoutingManifestJson(json(manifest));
|
||||
original = new SelfopSyncRun(); original.setExecutionPurpose("BUSINESS"); original.setId(20L); original.setTaskId(1L); original.setScopeId(10L);
|
||||
original.setSourceAccount("demo"); original.setWarehouseCode("A"); original.setAttemptNo(1);
|
||||
original.setFenceToken(6L); original.setStatus("FETCHING"); original.setBasePublishedRunId(11L);
|
||||
original.setReferenceSalesRunId(12L); original.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}");
|
||||
original.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0));
|
||||
inventory = new SelfopSyncScope(); inventory.setId(10L); inventory.setDataFamily("INVENTORY");
|
||||
inventory.setSourceAccount("demo"); inventory.setWarehouseCode("A"); inventory.setScopeHash("inventory-hash");
|
||||
inventory.setScopeJson("{\"quantityUnit\":\"PCS\"}"); inventory.setPublishedRunId(90L);
|
||||
inventory.setBusinessTimezone("UTC"); inventory.setStaleAfterSeconds(93600);
|
||||
var sales = new SelfopSyncScope(); sales.setId(30L); sales.setDataFamily("OUTBOUND"); sales.setPublishedRunId(80L);
|
||||
when(locks.lockTask(1L)).thenReturn(task); when(locks.lockRun(20L)).thenReturn(original);
|
||||
when(locks.lockScope(30L)).thenReturn(sales); when(scopes.selectById(10L)).thenReturn(inventory); when(scopes.selectOne(any())).thenReturn(sales);
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setPageSize(2);
|
||||
}
|
||||
|
||||
@Test void budgetYieldResumesSameAttemptWithFixedReferencesAndOriginalObservationStart() {
|
||||
String cursor = task.getRoutingManifestJson(); String snapshot = original.getScopeSnapshotJson();
|
||||
var started = original.getFetchStartedAtUtc();
|
||||
original.setFenceToken(lease.fence());
|
||||
service.yield(1L, 20L, lease);
|
||||
assertEquals("YIELDED", original.getStatus()); assertEquals("RETRY_WAIT", task.getStatus());
|
||||
assertNotNull(original.getFinishedAtUtc());
|
||||
when(runs.selectOne(any())).thenReturn(null, original);
|
||||
var resumed = service.begin(1L, lease);
|
||||
assertSame(original, resumed); assertEquals(20L, resumed.getId()); assertEquals(1, resumed.getAttemptNo());
|
||||
assertEquals(11L, resumed.getBasePublishedRunId()); assertEquals(12L, resumed.getReferenceSalesRunId());
|
||||
assertEquals(snapshot, resumed.getScopeSnapshotJson()); assertEquals(started, resumed.getFetchStartedAtUtc());
|
||||
assertEquals(cursor, task.getRoutingManifestJson()); assertEquals(1, task.getAttemptCount());
|
||||
assertEquals(7L, resumed.getFenceToken()); assertEquals("FETCHING", resumed.getStatus()); assertNull(resumed.getFinishedAtUtc());
|
||||
verify(runs, never()).insert(any(SelfopSyncRun.class));
|
||||
}
|
||||
|
||||
@Test void genuineFailureClearsCursorAndNewAttemptStartsAtPageOneWithItsOwnStageBatch() {
|
||||
original.setFenceToken(lease.fence());
|
||||
when(runs.selectCount(any())).thenReturn(1L);
|
||||
service.fail(1L, 20L, "SELFOP_STOCK_DUPLICATE", true, lease);
|
||||
assertEquals("FAILED", original.getStatus()); assertNull(task.getRoutingManifestJson());
|
||||
assertEquals("RETRY_WAIT", task.getStatus());
|
||||
// Verifies the final updateById cannot overwrite the explicit SQL-null cursor reset.
|
||||
verify(tasks).updateById(argThat((SelfopSyncTask t) -> t.getRoutingManifestJson() == null));
|
||||
when(runs.selectOne(any())).thenReturn(null);
|
||||
var fresh = service.begin(1L, lease);
|
||||
assertNotEquals(original.getId(), fresh.getId()); assertEquals(2, fresh.getAttemptNo());
|
||||
assertEquals(90L, fresh.getBasePublishedRunId()); assertEquals(80L, fresh.getReferenceSalesRunId());
|
||||
var client = mock(LingxingAnalyticsClient.class); var stages = mock(SelfopSyncStageMapper.class);
|
||||
var stock = new SelfopPayloads.Stock("demo", "customer", "A", "sku", 0, null,
|
||||
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(stock), 1, 2, 1L, 1));
|
||||
var tx = mock(TransactionTemplate.class);
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
var collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, service, config, tx);
|
||||
assertTrue(collector.collect(task, fresh, lease));
|
||||
var saved = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages).insert(saved.capture());
|
||||
assertEquals(fresh.getId(), saved.getValue().getStageBatchId()); assertEquals(fresh.getId(), saved.getValue().getRunId());
|
||||
assertEquals(1, saved.getValue().getFirstPageNo()); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2));
|
||||
}
|
||||
|
||||
@Test void exhaustedFailuresRetainOldPublishedPointerAndCannotCreateAnotherAttempt() {
|
||||
original.setFenceToken(lease.fence());
|
||||
when(runs.selectCount(any())).thenReturn((long) config.getMaxAttempts());
|
||||
service.fail(1L, 20L, "SOURCE_TIMEOUT", true, lease);
|
||||
assertEquals("DEAD", task.getStatus()); assertEquals("FAILED", original.getStatus());
|
||||
assertEquals(90L, inventory.getPublishedRunId()); assertEquals(11L, original.getBasePublishedRunId());
|
||||
assertThrows(IllegalStateException.class, () -> service.begin(1L, lease));
|
||||
verify(runs, never()).insert(any(SelfopSyncRun.class)); verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
|
||||
@Test void expiredFetchingAttemptStartsFreshWithoutReusingCursorEvenIfFailNeverRan() {
|
||||
// Process dies after accepted page one but before failure bookkeeping can clear its task cursor.
|
||||
String oldSnapshot = original.getScopeSnapshotJson(); var oldStarted = original.getFetchStartedAtUtc();
|
||||
when(runs.selectOne(any())).thenReturn(original, null);
|
||||
var replacement = service.begin(1L, lease);
|
||||
assertNotEquals(original.getId(), replacement.getId()); assertEquals(2, replacement.getAttemptNo());
|
||||
assertNull(task.getRoutingManifestJson()); assertEquals("ABORTED", original.getStatus());
|
||||
assertEquals("LEASE_EXPIRED", original.getErrorCode()); assertNotNull(original.getFinishedAtUtc());
|
||||
assertEquals(oldSnapshot, original.getScopeSnapshotJson()); assertEquals(oldStarted, original.getFetchStartedAtUtc());
|
||||
assertEquals(11L, original.getBasePublishedRunId()); assertEquals(12L, original.getReferenceSalesRunId());
|
||||
assertEquals(90L, replacement.getBasePublishedRunId()); assertEquals(80L, replacement.getReferenceSalesRunId());
|
||||
// Clearing NULL needs explicit SQL because default updateById omits null-valued properties.
|
||||
var update = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
|
||||
verify(tasks).update(isNull(), update.capture());
|
||||
var wrapper = (com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<?>) update.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("routing_manifest_json"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().stream().anyMatch(Objects::isNull));
|
||||
verify(runs).insert(replacement);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class SelfopProgressServiceTest {
|
||||
private SelfopSyncTask task(){var t=new SelfopSyncTask();t.setId(1L);t.setAttemptCount(1);t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(3L);return t;}
|
||||
private SelfopSyncRun run(){var r=new SelfopSyncRun();r.setId(2L);r.setTaskId(1L);r.setAttemptNo(1);r.setFenceToken(8L);return r;}
|
||||
@Test void repeatedCheckpointUsesCommittedCountAndReliablePhaseDenominatorOnly() {
|
||||
var t=task();var r=run();
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1);
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1);
|
||||
var p=read(t.getProgressJson(),Map.class);
|
||||
assertEquals(3,p.get("completed"));assertEquals("ACCOUNT_SHARED",p.get("progressScope"));assertEquals("TASK_CUMULATIVE",p.get("countBasis"));
|
||||
assertEquals(37.5,((Number)p.get("phasePercent")).doubleValue());assertFalse(p.containsKey("totalPercent"));
|
||||
assertNotNull(r.getHeartbeatAtUtc());assertTrue(t.getProgressJson().getBytes(StandardCharsets.UTF_8).length<=8192);
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",2,null,2);
|
||||
p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals(false,p.get("totalKnown"));assertEquals(2,p.get("scanRound"));
|
||||
}
|
||||
@Test void inventoryRowsAndEmptyResultDoNotBecomeSkuCountOrWholeJobSuccess() {
|
||||
var t=task();t.setTaskRole("WAREHOUSE_SYNC");var r=run();
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","INVENTORY_ROW",0,0L,1);
|
||||
var p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals("ATTEMPT_ONLY",p.get("countBasis"));
|
||||
assertEquals("WAREHOUSE",p.get("progressScope"));assertNull(t.getStatus());
|
||||
assertThrows(IllegalStateException.class,()->SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","SKU",1,1L,1));
|
||||
}
|
||||
@Test void frozenAttemptDoesNotFollowLaterAttemptProgress() {
|
||||
var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,10L,1);
|
||||
SelfopProgressProjection.freezeRun(t,r);String frozen=r.getProgressSnapshotJson();
|
||||
var next=run();next.setId(4L);next.setAttemptNo(2);
|
||||
SelfopProgressProjection.applyCheckpoint(t,next,"DETAIL","ORDER",7,10L,1);
|
||||
SelfopProgressProjection.freezeRun(t,r);assertEquals(frozen,r.getProgressSnapshotJson());
|
||||
var unfrozen=run();assertThrows(IllegalStateException.class,()->SelfopProgressProjection.freezeRun(t,unfrozen));
|
||||
}
|
||||
@Test void terminalPhaseKeepsReliableStageHistoryAndUtcStringsForReadContract() {
|
||||
var t=task();t.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));t.setQueryUntilUtc(t.getQueryFromUtc().plusDays(1));var r=run();
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,4L,1);
|
||||
SelfopProgressProjection.applyCheckpoint(t,r,"SAMPLE_DONE","NONE",0,null,1);
|
||||
var p=read(t.getProgressJson(),Map.class);assertFalse(p.containsKey("phases"));
|
||||
var stages=(List<?>)p.get("stages");assertEquals(2,stages.size());var detail=(Map<?,?>)stages.get(0);
|
||||
assertEquals("DETAIL",detail.get("phase"));assertEquals(4,detail.get("completed"));assertEquals(true,detail.get("totalKnown"));
|
||||
assertEquals(Instant.parse("2026-10-11T16:00:00Z"),Instant.parse((String)detail.get("sliceFrom")));
|
||||
assertDoesNotThrow(()->Instant.parse((String)detail.get("stageStartedAt")));
|
||||
assertDoesNotThrow(()->Instant.parse((String)p.get("stageStartedAt")));
|
||||
}
|
||||
@Test void staleFenceCannotChangeCheckpointOrHeartbeat() {
|
||||
var locks=mock(SelfopCoordinationMapper.class);var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var leases=mock(SelfopLeaseService.class);
|
||||
var lease=new SelfopLeaseService.Lease(3,"old",7,true);when(locks.lockTask(1)).thenReturn(task());when(locks.lockRun(2)).thenReturn(run());
|
||||
var service=new SelfopProgressService(locks,tasks,runs,leases);
|
||||
assertThrows(IllegalStateException.class,()->service.checkpoint(1,2,lease,"DETAIL","ORDER",1,3L,1));
|
||||
assertThrows(IllegalStateException.class,()->service.heartbeat(1,2,lease));
|
||||
verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
|
||||
var order=inOrder(leases,locks);order.verify(leases).verify(lease);order.verify(locks).lockTask(1);order.verify(locks).lockRun(2);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopReferenceRecoveryTest {
|
||||
private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class);
|
||||
private final SelfopLeaseService leases = mock(SelfopLeaseService.class);
|
||||
private final SelfopTaskService taskService = mock(SelfopTaskService.class);
|
||||
private final SelfopDiscoveryService discovery = mock(SelfopDiscoveryService.class);
|
||||
private final TransactionTemplate tx = mock(TransactionTemplate.class);
|
||||
private final SelfopReferenceRecovery recovery = new SelfopReferenceRecovery(
|
||||
stages, tasks, scopes, accounts, locks, leases, taskService, discovery, tx);
|
||||
private final String key = "b".repeat(64);
|
||||
private final Map<Long, SelfopLeaseService.Lease> held = new LinkedHashMap<>();
|
||||
private final SelfopLeaseService.Lease accountLease = new SelfopLeaseService.Lease(50, "account-worker", 2, true);
|
||||
private final SelfopSyncTask original = new SelfopSyncTask();
|
||||
private final SelfopSyncTask refresh = new SelfopSyncTask();
|
||||
private final SelfopSyncStage latest = new SelfopSyncStage();
|
||||
private String returnedWarehouse = "B";
|
||||
|
||||
@BeforeEach
|
||||
void prepare() {
|
||||
original.setId(1L); original.setSourceAccount("demo"); original.setWarehouseCode("A");
|
||||
original.setExecutionPurpose("BUSINESS");
|
||||
original.setScopeHash("fixed-scope"); original.setApplyState("BLOCKED");
|
||||
original.setAffectedWarehouseCodesJson(json(List.of("A", "B")));
|
||||
var manifest = new SelfopRoutingManifest();
|
||||
var ref = new SelfopRoutingManifest.Entry();
|
||||
ref.setEffectiveStageId(20L); ref.setCustomerCode("customer"); ref.setDeliveryNo("delivery"); ref.setWarehouseCode("A");
|
||||
manifest.getObjects().put(key, ref); original.setRoutingManifestJson(json(manifest));
|
||||
var current = new SelfopSyncStage(); current.setId(20L); current.setObservationSequence(1L);
|
||||
latest.setId(21L); latest.setObservationSequence(2L); latest.setWarehouseCode("B");
|
||||
when(stages.selectById(20L)).thenReturn(current); when(stages.selectOne(any())).thenReturn(latest);
|
||||
var account = new SelfopOutboundDiscoveryScope(); account.setId(50L);
|
||||
when(accounts.selectOne(any())).thenReturn(account);
|
||||
when(leases.acquireDiscovery(eq(50L), anyString())).thenReturn(accountLease);
|
||||
for (long id : new long[]{10, 11}) {
|
||||
held.put(id, new SelfopLeaseService.Lease(id, "warehouse-worker", 3, false));
|
||||
var scope = new SelfopSyncScope(); scope.setWarehouseCode(id == 10 ? "A" : "B");
|
||||
when(scopes.selectById(id)).thenReturn(scope);
|
||||
}
|
||||
refresh.setId(40L); refresh.setStatus("READY"); refresh.setExecutionPurpose("BUSINESS");
|
||||
when(taskService.create(any(SelfopSyncTask.class))).thenReturn(refresh);
|
||||
when(tasks.selectById(40L)).thenReturn(refresh);
|
||||
var run = new SelfopSyncRun(); run.setId(80L); run.setExecutionPurpose("BUSINESS");
|
||||
when(taskService.begin(40L, accountLease)).thenReturn(run);
|
||||
when(locks.lockTask(1L)).thenReturn(original);
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0);
|
||||
callback.accept(mock(TransactionStatus.class)); return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
doAnswer(invocation -> {
|
||||
var newManifest = new SelfopRoutingManifest(); var result = new SelfopRoutingManifest.Entry();
|
||||
result.setEffectiveStageId(22L); result.setWarehouseCode(returnedWarehouse);
|
||||
newManifest.getObjects().put(key, result);
|
||||
refresh.setRoutingManifestJson(json(newManifest)); refresh.setStatus("SUCCEEDED"); return null;
|
||||
}).when(discovery).collect(refresh, run, accountLease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newerObservationRequiresActualRecheckAndKeepsOriginalHalfApplyBarrier() {
|
||||
recovery.refresh(original, held);
|
||||
var reference = read(original.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key);
|
||||
assertEquals(22L, reference.getEffectiveStageId()); assertEquals("B", reference.getWarehouseCode());
|
||||
assertEquals(40L, reference.getRefreshTaskId()); assertEquals(2L, reference.getNewestSeenSequence());
|
||||
assertEquals("BLOCKED", original.getApplyState());
|
||||
assertEquals(json(List.of("A", "B")), original.getAffectedWarehouseCodesJson());
|
||||
verify(taskService).create(argThat(t -> "ACCOUNT_DISCOVERY".equals(t.getTaskRole())
|
||||
&& "RECHECK".equals(t.getTaskType())
|
||||
&& Objects.equals(1L, read(t.getQueryScopeJson(), SelfopDiscoveryService.Query.class).parentTaskId())));
|
||||
verify(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease));
|
||||
verify(tasks).updateById(original); verify(leases).release(accountLease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedObservationDoesNotRequestAnotherSourceRead() {
|
||||
latest.setObservationSequence(1L);
|
||||
var before = original.getRoutingManifestJson(); recovery.refresh(original, held);
|
||||
assertEquals(before, original.getRoutingManifestJson());
|
||||
verifyNoInteractions(taskService, discovery, accounts, locks, leases);
|
||||
}
|
||||
|
||||
@Test
|
||||
void budgetYieldCannotReplaceEffectiveReferenceOrClearBarrier() {
|
||||
doAnswer(invocation -> { refresh.setStatus("RETRY_WAIT"); return null; })
|
||||
.when(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease));
|
||||
var before = original.getRoutingManifestJson();
|
||||
var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held));
|
||||
assertEquals("SELFOP_RECHECK_YIELDED", error.getMessage());
|
||||
assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState());
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recheckMovingToAnUnleasedWarehouseMustReacquireBeforeAnyParentWrite() {
|
||||
returnedWarehouse = "C";
|
||||
var before = original.getRoutingManifestJson();
|
||||
var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held));
|
||||
assertEquals("LOCK_SET_CHANGED", error.getMessage());
|
||||
assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState());
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncRunMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Gate assertions are offline; actual transaction rollback/concurrency needs database acceptance. */
|
||||
class SelfopSalesPublisherTest {
|
||||
final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class);
|
||||
final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class);
|
||||
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
|
||||
final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
|
||||
final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
|
||||
final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class);
|
||||
final SelfopSalesPublisher publisher=new SelfopSalesPublisher(locks,scopes,runs,tasks,config,aggregates);
|
||||
SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task;
|
||||
|
||||
@BeforeEach void setup() {
|
||||
scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("synthetic-warehouse");
|
||||
scope.setDataFamily("OUTBOUND"); scope.setScopeHash("confirmed-synthetic-scope"); scope.setSourceTimezone("UTC");
|
||||
scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1));
|
||||
scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1);
|
||||
run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash());
|
||||
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);
|
||||
run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2));
|
||||
run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0));
|
||||
task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED");
|
||||
when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task);
|
||||
}
|
||||
private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); }
|
||||
private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); }
|
||||
|
||||
@Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() {
|
||||
assertFalse(config.isSalesPublicationEnabled());
|
||||
IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3));
|
||||
assertEquals("SELFOP_SALES_PUBLICATION_DISABLED",error.getMessage()); verifyNoInteractions(locks); noWrites();
|
||||
}
|
||||
@Test void expiredLeaseCannotSwitchPointer() {
|
||||
enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
|
||||
assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites();
|
||||
}
|
||||
@Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() {
|
||||
enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD");
|
||||
when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L);
|
||||
IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3));
|
||||
assertEquals("HALF_APPLIED_FACTS",error.getMessage()); noWrites();
|
||||
verify(locks,never()).lockTask(20L);
|
||||
}
|
||||
@Test void revisionMismatchRejectsOtherwiseCompleteCandidate() {
|
||||
enableSyntheticPublication(); scope.setFactRevision(8L);
|
||||
IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3));
|
||||
assertEquals("SELFOP_CANDIDATE_OBSOLETE",error.getMessage()); noWrites();
|
||||
}
|
||||
@Test void newObservationRejectsCandidateEvenWithoutFactRevisionChange() {
|
||||
enableSyntheticPublication();
|
||||
when(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())).thenReturn(1L);
|
||||
var error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3));
|
||||
assertEquals("SELFOP_CANDIDATE_OBSERVATION_CHANGED",error.getMessage());noWrites();
|
||||
verify(locks,never()).lockTask(20L);
|
||||
}
|
||||
@Test void unconfirmedSourceTimezoneOrUnitCannotPublish() {
|
||||
enableSyntheticPublication(); scope.setSourceTimezone(null);
|
||||
assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites();
|
||||
scope.setSourceTimezone("UTC"); config.setQuantityUnit(null);
|
||||
assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites();
|
||||
}
|
||||
@Test void completeCandidatePublishesScopeRunAndTaskInsideOneTransactionBoundary() throws Exception {
|
||||
enableSyntheticPublication();
|
||||
publisher.publish(1,10,"worker",3);
|
||||
assertEquals(10L,scope.getPublishedRunId()); assertEquals(7L,scope.getPublishedFactRevision()); assertEquals(0,scope.getRebuildRequired());
|
||||
assertEquals(run.getCoveredFromUtc(),scope.getDiscoveryFromUtc()); assertEquals(run.getCoveredUntilUtc(),scope.getDiscoveryUntilUtc());
|
||||
assertEquals("PUBLISHED",run.getStatus()); assertNotNull(run.getPublishedAtUtc()); assertEquals("SUCCEEDED",task.getStatus());
|
||||
var ordered=inOrder(locks,scopes,runs,tasks);
|
||||
ordered.verify(locks).lockScope(1L); ordered.verify(locks).lockRun(10L);
|
||||
ordered.verify(locks).barriers(scope.getSourceAccount(),scope.getWarehouseCode()); ordered.verify(locks).lockTask(20L);
|
||||
ordered.verify(runs).update(isNull(),any());
|
||||
ordered.verify(scopes).updateById(any(SelfopSyncScope.class));
|
||||
ordered.verify(runs).updateById(any(SelfopSyncRun.class)); ordered.verify(tasks).updateById(any(SelfopSyncTask.class));
|
||||
assertNotNull(SelfopSalesPublisher.class.getMethod("publish",long.class,long.class,String.class,long.class).getAnnotation(Transactional.class));
|
||||
assertEquals(10L,task.getResultPublishedRunId());assertNotNull(run.getProgressSnapshotJson());
|
||||
// Publisher constructor has no inventory repositories or inventory publication prerequisite.
|
||||
verifyNoMoreInteractions(scopes,runs,tasks);
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopSchedulePlannerTest {
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final SelfopTaskService taskService = mock(SelfopTaskService.class);
|
||||
private final SelfopExecutionService execution = mock(SelfopExecutionService.class);
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class);
|
||||
private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class);
|
||||
private final TransactionTemplate tx = mock(TransactionTemplate.class);
|
||||
private final SelfopSchedulePlanner planner = new SelfopSchedulePlanner(config, taskService, execution,
|
||||
scopes, accounts, tasks, orders, locks, tx);
|
||||
|
||||
@Test void disabledTickDoesNotInitializePlanPersistOrExecuteAnything() {
|
||||
planner.tick();
|
||||
verifyNoInteractions(taskService, execution, scopes, accounts, tasks, orders, locks, tx);
|
||||
}
|
||||
|
||||
@Test void unknownSourceTimezoneStillPlansBothInventoriesAndExecutesDurableDueWork() {
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production");
|
||||
assertThat(config.getSourceQueryTimezone()).isNull();
|
||||
var a = scope(10L, "WH-A", "INVENTORY");
|
||||
var b = scope(11L, "WH-B", "INVENTORY");
|
||||
var outbound = scope(12L, "WH-A", "OUTBOUND");
|
||||
outbound.setSourceTimezone(null);
|
||||
var dueA = new SelfopSyncTask(); dueA.setId(101L);
|
||||
var dueB = new SelfopSyncTask(); dueB.setId(102L);
|
||||
when(taskService.initialize()).thenReturn(List.of(a, b, outbound));
|
||||
when(taskService.due(10)).thenReturn(List.of(dueA, dueB));
|
||||
var subject = spy(planner);
|
||||
doNothing().when(subject).plan(any(SelfopSyncScope.class), any(LocalDateTime.class));
|
||||
doNothing().when(subject).review(any(SelfopSyncScope.class), any(LocalDateTime.class));
|
||||
// A busy warehouse must not suppress the other warehouse's independent durable work.
|
||||
doThrow(new IllegalStateException("SELFOP_LEASE_BUSY")).when(execution).execute(101L);
|
||||
subject.tick();
|
||||
verify(subject).plan(eq(a), any(LocalDateTime.class));
|
||||
verify(subject).plan(eq(b), any(LocalDateTime.class));
|
||||
verify(subject, never()).plan(eq(outbound), any(LocalDateTime.class));
|
||||
verify(subject, never()).review(any(SelfopSyncScope.class), any(LocalDateTime.class));
|
||||
verify(execution).execute(101L);
|
||||
verify(execution).execute(102L);
|
||||
verifyNoInteractions(accounts, orders);
|
||||
}
|
||||
|
||||
@Test void firstInventoryCreatedAfterDailySlotSchedulesCurrentObservationInsteadOfYesterday() {
|
||||
directTransactions();
|
||||
var scope = scope(10L, "WH-A", "INVENTORY");
|
||||
var now = LocalDateTime.of(2026, 10, 12, 14, 30);
|
||||
scope.setCreatedAt(now.minusHours(1));
|
||||
when(locks.lockScope(10L)).thenReturn(scope);
|
||||
planner.plan(scope, now);
|
||||
var captured = ArgumentCaptor.forClass(SelfopSyncTask.class);
|
||||
verify(taskService).create(captured.capture());
|
||||
var task = captured.getValue();
|
||||
assertThat(task.getTaskType()).isEqualTo("SCHEDULED");
|
||||
assertThat(task.getPlannedAtUtc()).isEqualTo(now);
|
||||
assertThat(task.getWarehouseCode()).isEqualTo("WH-A");
|
||||
assertThat(task.getScopeId()).isEqualTo(10L);
|
||||
assertThat(task.getQueryFromUtc()).isNull();
|
||||
assertThat(task.getQueryUntilUtc()).isNull();
|
||||
assertThat(scope.getInitialFromUtc()).isNull();
|
||||
assertThat(scope.getInitialUntilUtc()).isNull();
|
||||
verifyNoInteractions(execution, accounts, orders);
|
||||
}
|
||||
|
||||
@Test void missedInventorySlotsPersistLatestDuePlanWithoutPretendingToObserveHistoricalStock() {
|
||||
directTransactions();
|
||||
var scope = scope(10L, "WH-A", "INVENTORY");
|
||||
scope.setCreatedAt(LocalDateTime.of(2026, 10, 1, 0, 0));
|
||||
var previous = new SelfopSyncTask();
|
||||
previous.setPlannedAtUtc(LocalDateTime.of(2026, 10, 10, 8, 0));
|
||||
when(locks.lockScope(10L)).thenReturn(scope);
|
||||
when(tasks.selectOne(any())).thenReturn(previous);
|
||||
planner.plan(scope, LocalDateTime.of(2026, 10, 12, 14, 30));
|
||||
var captured = ArgumentCaptor.forClass(SelfopSyncTask.class);
|
||||
verify(taskService).create(captured.capture());
|
||||
var task = captured.getValue();
|
||||
assertThat(task.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026, 10, 12, 8, 0));
|
||||
assertThat(task.getRequestKey()).contains("WH-A", "INVENTORY", "2026-10-12T08:00");
|
||||
assertThat(task.getQueryFromUtc()).isNull();
|
||||
assertThat(task.getQueryUntilUtc()).isNull();
|
||||
assertThat(task.getRoutingManifestJson()).isNull();
|
||||
// Planning creates no execution run or source observation; actual fetch records those later.
|
||||
verifyNoInteractions(execution, accounts, orders);
|
||||
verify(scopes, never()).updateById(any(SelfopSyncScope.class));
|
||||
}
|
||||
|
||||
@Test void completedKnownOrderAndHistoryRoundsRemainClearedThroughFinalScopeWrite() {
|
||||
directTransactions();
|
||||
var scope = scope(10L, "WH-A", "OUTBOUND");
|
||||
var now = LocalDateTime.of(2026, 10, 12, 14, 30);
|
||||
scope.setKnownOrderCursorId(99L);
|
||||
scope.setKnownOrderRoundMaxId(99L);
|
||||
scope.setKnownOrderRoundStartedAtUtc(now.minusDays(1));
|
||||
scope.setInitialFromUtc(now.minusMonths(2));
|
||||
scope.setDiscoveryUntilUtc(now.minusHours(1));
|
||||
scope.setHistoryRoundFromUtc(scope.getInitialFromUtc());
|
||||
scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc());
|
||||
scope.setHistorySliceCursorUtc(scope.getDiscoveryUntilUtc());
|
||||
when(locks.lockScope(10L)).thenReturn(scope);
|
||||
// No further known order inside this fixed round; the history cursor is already at its end.
|
||||
when(orders.selectOne(any())).thenReturn(null);
|
||||
planner.review(scope, now);
|
||||
var captured = ArgumentCaptor.forClass(SelfopSyncScope.class);
|
||||
verify(scopes).updateById(captured.capture());
|
||||
var persisted = captured.getValue();
|
||||
assertThat(persisted.getKnownOrderCursorId()).isNull();
|
||||
assertThat(persisted.getKnownOrderRoundMaxId()).isNull();
|
||||
assertThat(persisted.getKnownOrderRoundFinishedAtUtc()).isEqualTo(now);
|
||||
assertThat(persisted.getHistoryRoundUntilUtc()).isNull();
|
||||
assertThat(persisted.getHistorySliceCursorUtc()).isNull();
|
||||
assertThat(persisted.getHistoryRoundFinishedAtUtc()).isEqualTo(now);
|
||||
verify(scopes, times(2)).update(isNull(), any());
|
||||
verifyNoInteractions(taskService, execution, accounts, tasks);
|
||||
}
|
||||
|
||||
@Test void unpublishedInitialBackfillIsNotRepeatedAcrossEightAndTwentySlots() {
|
||||
var initial=LocalDateTime.of(2026,10,12,0,0); // Beijing 08:00
|
||||
var scope=outboundScope(initial);var state=persistPlans(scope);
|
||||
planner.plan(scope,initial);
|
||||
var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList();
|
||||
assertThat(history).isNotEmpty();
|
||||
assertThat(history.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0));
|
||||
assertThat(history.get(history.size()-1).getQueryUntilUtc()).isEqualTo(initial);
|
||||
assertThat(history).allSatisfy(t->assertThat(t.getRequestKey()).startsWith("initial:"));
|
||||
int firstCount=state.created.size();
|
||||
var evening=initial.plusHours(12);
|
||||
planner.plan(scope,evening);
|
||||
var second=state.created.subList(firstCount,state.created.size());
|
||||
assertThat(second).noneSatisfy(t->assertThat(t.getTaskType()).isEqualTo("BACKFILL"));
|
||||
var increments=second.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList();
|
||||
assertThat(increments).hasSize(1);
|
||||
assertThat(increments.get(0).getTaskType()).isEqualTo("SCHEDULED");
|
||||
assertThat(increments.get(0).getQueryFromUtc()).isEqualTo(initial);
|
||||
assertThat(increments.get(0).getQueryUntilUtc()).isEqualTo(evening);
|
||||
assertThat(scope.getDiscoveryUntilUtc()).isNull(); // No source execution or publication occurred.
|
||||
assertThat(scope.getInitialUntilUtc()).isEqualTo(initial);
|
||||
int afterEvening=state.created.size();
|
||||
planner.plan(scope,evening.plusMinutes(1));
|
||||
assertThat(state.created).hasSize(afterEvening);
|
||||
assertThat(state.created.stream().map(SelfopSyncTask::getRequestKey).toList()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@Test void persistedRollingMonthWindowKeepsItsBoundaryWhenFirstPlanningIsDelayed() {
|
||||
var initial=LocalDateTime.of(2026,3,31,0,0);
|
||||
var scope=outboundScope(initial);scope.setInitialUntilUtc(initial);
|
||||
scope.setInitialFromUtc(LocalDateTime.of(2026,1,31,0,0));
|
||||
var state=persistPlans(scope);
|
||||
planner.plan(scope,initial.plusHours(12));
|
||||
var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList();
|
||||
assertThat(history).hasSize(59); // Calendar months, not a fixed sixty-day subtraction.
|
||||
assertThat(history.get(0).getQueryFromUtc()).isEqualTo(scope.getInitialFromUtc());
|
||||
assertThat(history.get(58).getQueryUntilUtc()).isEqualTo(initial);
|
||||
assertThat(history).allSatisfy(t->assertThat(t.getQueryUntilUtc()).isBeforeOrEqualTo(initial));
|
||||
var incremental=state.created.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())&&"SCHEDULED".equals(t.getTaskType())).toList();
|
||||
assertThat(incremental).hasSize(1);
|
||||
assertThat(incremental.get(0).getQueryFromUtc()).isEqualTo(initial);
|
||||
assertThat(incremental.get(0).getQueryUntilUtc()).isEqualTo(initial.plusHours(12));
|
||||
assertThat(scope.getInitialUntilUtc()).isEqualTo(initial);
|
||||
}
|
||||
|
||||
@Test void missedLaterSlotsQueueEveryIncrementalIntervalWithoutMovingInitialWindow() {
|
||||
var initial=LocalDateTime.of(2026,10,12,0,0);var scope=outboundScope(initial);
|
||||
var state=persistPlans(scope);planner.plan(scope,initial);
|
||||
int before=state.created.size();
|
||||
planner.plan(scope,initial.plusDays(4).plusHours(12));
|
||||
var incremental=state.created.subList(before,state.created.size()).stream()
|
||||
.filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList();
|
||||
assertThat(incremental).hasSize(5);
|
||||
var cursor=initial;
|
||||
for(var slice:incremental){
|
||||
assertThat(slice.getTaskType()).isEqualTo("SCHEDULED");
|
||||
assertThat(slice.getQueryFromUtc()).isEqualTo(cursor);
|
||||
assertThat(slice.getQueryUntilUtc()).isAfter(cursor).isBeforeOrEqualTo(cursor.plusDays(1));
|
||||
cursor=slice.getQueryUntilUtc();
|
||||
}
|
||||
assertThat(cursor).isEqualTo(initial.plusDays(4).plusHours(12));
|
||||
assertThat(scope.getInitialFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0));
|
||||
assertThat(scope.getInitialUntilUtc()).isEqualTo(initial);
|
||||
}
|
||||
|
||||
private SelfopSyncScope outboundScope(LocalDateTime created) {
|
||||
var scope=scope(10L,"WH-A","OUTBOUND");scope.setCreatedAt(created);
|
||||
scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai");
|
||||
scope.setScheduleCron("0 0 8,20 * * *");return scope;
|
||||
}
|
||||
private static class PlannedState {
|
||||
final List<SelfopSyncTask> created=new ArrayList<>();
|
||||
final Map<String,SelfopSyncTask> byKey=new HashMap<>();
|
||||
SelfopSyncTask latest;
|
||||
}
|
||||
private PlannedState persistPlans(SelfopSyncScope scope) {
|
||||
directTransactions();config.setSourceQueryTimezone("Asia/Shanghai");
|
||||
config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse());
|
||||
var account=new SelfopOutboundDiscoveryScope();account.setId(30L);
|
||||
when(accounts.selectOne(any())).thenReturn(account);when(locks.lockScope(scope.getId())).thenReturn(scope);
|
||||
var state=new PlannedState();when(tasks.selectOne(any())).thenAnswer(call->state.latest);
|
||||
when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call->{
|
||||
SelfopSyncTask task=call.getArgument(0);var prior=state.byKey.get(task.getRequestKey());
|
||||
if(prior!=null){assertThat(task.getRequestHash()).isEqualTo(prior.getRequestHash());return prior;}
|
||||
state.byKey.put(task.getRequestKey(),task);state.created.add(task);
|
||||
if("WAREHOUSE_SYNC".equals(task.getTaskRole()))state.latest=task;
|
||||
return task;
|
||||
});return state;
|
||||
}
|
||||
|
||||
private SelfopSyncScope scope(long id, String warehouse, String family) {
|
||||
var scope = new SelfopSyncScope();
|
||||
scope.setId(id); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode(warehouse);
|
||||
scope.setDataFamily(family); scope.setBusinessTimezone("UTC"); scope.setScheduleCron("0 0 8 * * *");
|
||||
scope.setScopeHash("synthetic-scope-hash");
|
||||
scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}");
|
||||
return scope;
|
||||
}
|
||||
private void directTransactions() {
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production");
|
||||
doAnswer(call -> {
|
||||
Consumer<TransactionStatus> action = call.getArgument(0);
|
||||
action.accept(mock(TransactionStatus.class));
|
||||
return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
}
|
||||
}
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopTaskServiceTest {
|
||||
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
|
||||
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class);
|
||||
private final SelfopLeaseService leases = mock(SelfopLeaseService.class);
|
||||
private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
|
||||
private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases);
|
||||
private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "synthetic-worker", 7L, false);
|
||||
|
||||
@BeforeEach void configureBusiness() {config.setCollectionMode("BUSINESS");config.setEnvironment("production");}
|
||||
|
||||
@Test void beginFixesTopLevelTimezoneStalenessAndInitialWindowInVersionSnapshot() {
|
||||
var task = task("READY");
|
||||
var scope = scope("OUTBOUND");
|
||||
scope.setInitialFromUtc(LocalDateTime.of(2026, 9, 12, 0, 0));
|
||||
scope.setInitialUntilUtc(LocalDateTime.of(2026, 10, 12, 0, 0));
|
||||
when(locks.lockTask(1L)).thenReturn(task);
|
||||
when(scopes.selectById(10L)).thenReturn(scope);
|
||||
|
||||
var run = service.begin(1L, lease);
|
||||
Map<?, ?> snapshot = read(run.getScopeSnapshotJson(), Map.class);
|
||||
assertThat(snapshot.get("businessTimezone")).isEqualTo("America/New_York");
|
||||
assertThat(snapshot.get("sourceTimezone")).isEqualTo("UTC");
|
||||
assertThat(snapshot.get("staleAfterSeconds")).isEqualTo(43200);
|
||||
assertThat(snapshot.get("initialFromUtc")).isEqualTo("2026-09-12T00:00:00");
|
||||
assertThat(snapshot.get("initialUntilUtc")).isEqualTo("2026-10-12T00:00:00");
|
||||
assertThat(snapshot.get("customerMode")).isEqualTo("ALL_AUTHORIZED");
|
||||
assertThat(run.getBasePublishedRunId()).isEqualTo(90L);
|
||||
assertThat(run.getFenceToken()).isEqualTo(7L);
|
||||
assertThat(run.getAttemptNo()).isEqualTo(1);
|
||||
var order = inOrder(leases, locks, runs, tasks);
|
||||
order.verify(leases).verify(lease);
|
||||
order.verify(locks).lockTask(1L);
|
||||
order.verify(runs).selectOne(any());
|
||||
order.verify(runs).insert(run);
|
||||
order.verify(tasks).updateById(task);
|
||||
}
|
||||
|
||||
@Test void unknownSourceTimezoneAndUnestablishedInitialWindowStayExplicitlyUnknown() {
|
||||
var scope = scope("OUTBOUND");
|
||||
scope.setSourceTimezone(null);
|
||||
when(locks.lockTask(1L)).thenReturn(task("READY"));
|
||||
when(scopes.selectById(10L)).thenReturn(scope);
|
||||
Map<?, ?> snapshot = read(service.begin(1L, lease).getScopeSnapshotJson(), Map.class);
|
||||
assertThat(snapshot.containsKey("sourceTimezone")).isTrue();
|
||||
assertThat(snapshot.containsKey("initialFromUtc")).isTrue();
|
||||
assertThat(snapshot.containsKey("initialUntilUtc")).isTrue();
|
||||
assertThat(snapshot.get("sourceTimezone")).isNull();
|
||||
assertThat(snapshot.get("initialFromUtc")).isNull();
|
||||
assertThat(snapshot.get("initialUntilUtc")).isNull();
|
||||
}
|
||||
|
||||
@Test void staleFenceRejectsBeginBeforeAnyTaskLockOrAttemptMutation() {
|
||||
doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease);
|
||||
assertThatThrownBy(() -> service.begin(1L, lease)).hasMessage("SELFOP_FENCE_REJECTED");
|
||||
verifyNoInteractions(locks, tasks, runs, scopes, accounts);
|
||||
}
|
||||
|
||||
@Test void validLeaseForAnotherWarehouseScopeCannotBeginThisTask() {
|
||||
when(locks.lockTask(1L)).thenReturn(task("READY"));
|
||||
var wrongScope = new SelfopLeaseService.Lease(11L, "synthetic-worker", 7L, false);
|
||||
assertThatThrownBy(() -> service.begin(1L, wrongScope)).hasMessage("SELFOP_TASK_LEASE_SCOPE_MISMATCH");
|
||||
verify(leases).verify(wrongScope);
|
||||
verifyNoInteractions(tasks, runs, scopes, accounts);
|
||||
}
|
||||
|
||||
@Test void sameManualRetryCommandDoesNotResetRunningOrFailedOriginalTask() {
|
||||
for (String status : List.of("RUNNING", "FAILED")) {
|
||||
reset(locks, tasks, accounts, runs, scopes);
|
||||
var original = task(status);
|
||||
original.setApplyState("BLOCKED");
|
||||
original.setAttemptCount(8);
|
||||
original.setRequestedBy("previous-operator");
|
||||
var retryAudit = task("SUCCEEDED");
|
||||
retryAudit.setRequestKey("retry:command-1");
|
||||
retryAudit.setRequestHash(hash(List.of(1L, "command-1", "offline retry")));
|
||||
when(tasks.selectById(1L)).thenReturn(original);
|
||||
when(tasks.selectOne(any())).thenReturn(retryAudit);
|
||||
|
||||
assertThat(service.retry(1L, "command-1", "new-operator", "offline retry")).isSameAs(original);
|
||||
assertThat(original.getStatus()).isEqualTo(status);
|
||||
assertThat(original.getApplyState()).isEqualTo("BLOCKED");
|
||||
assertThat(original.getAttemptCount()).isEqualTo(8);
|
||||
assertThat(original.getRequestedBy()).isEqualTo("previous-operator");
|
||||
assertThat(original.getNextRetryAtUtc()).isNull();
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verify(tasks, never()).insert(any(SelfopSyncTask.class));
|
||||
verifyNoInteractions(locks, accounts, runs, scopes);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void duplicateRetryKeyForAnotherTaskIsRefusedWithoutChangingOriginal() {
|
||||
var original = task("RUNNING");
|
||||
var prior = task("SUCCEEDED");
|
||||
prior.setRequestHash(hash(List.of(2L, "command-1", "offline retry")));
|
||||
when(tasks.selectById(1L)).thenReturn(original);
|
||||
when(tasks.selectOne(any())).thenReturn(prior);
|
||||
assertThatThrownBy(() -> service.retry(1L, "command-1", "operator", "offline retry"))
|
||||
.hasMessage("SELFOP_IDEMPOTENCY_CONFLICT");
|
||||
assertThat(original.getStatus()).isEqualTo("RUNNING");
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verify(tasks, never()).insert(any(SelfopSyncTask.class));
|
||||
verifyNoInteractions(locks, accounts, runs, scopes);
|
||||
}
|
||||
|
||||
@Test void firstRetryLocksWarehouseScopeBeforeTaskAndPreservesDurableApplyBarrier() {
|
||||
var original = task("DEAD");
|
||||
original.setApplyState("BLOCKED");
|
||||
original.setAttemptCount(5);
|
||||
var unlockedScope = scope("OUTBOUND");
|
||||
when(tasks.selectById(1L)).thenReturn(original);
|
||||
when(locks.lockScope(10L)).thenReturn(unlockedScope);
|
||||
when(locks.lockTask(1L)).thenReturn(original);
|
||||
assertThat(service.retry(1L, "first-command", "new-operator", "offline retry")).isSameAs(original);
|
||||
var order = inOrder(locks);
|
||||
order.verify(locks).lockScope(10L);
|
||||
order.verify(locks).lockTask(1L);
|
||||
assertThat(original.getStatus()).isEqualTo("READY");
|
||||
assertThat(original.getApplyState()).isEqualTo("BLOCKED");
|
||||
assertThat(original.getAttemptCount()).isEqualTo(5);
|
||||
var audit = org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class);
|
||||
verify(tasks).insert(audit.capture());
|
||||
assertThat(audit.getValue().getRequestKey()).isEqualTo("retry:first-command");
|
||||
assertThat(audit.getValue().getStatus()).isEqualTo("SUCCEEDED");
|
||||
assertThat(audit.getValue().getUpstreamTaskId()).isEqualTo(1L);
|
||||
assertThat(original.getTaskNo()).isEqualTo("LXCOL-20261012-00000001");
|
||||
assertThat(read(audit.getValue().getQueryScopeJson(),Map.class)).containsEntry("reason","offline retry")
|
||||
.containsEntry("resumesTaskNo",original.getTaskNo());
|
||||
verify(tasks).updateById(original);
|
||||
}
|
||||
|
||||
@Test void firstRetryCannotResetTaskWhileScopeHasLiveExecutionLease() {
|
||||
var original = task("RUNNING");
|
||||
original.setApplyState("APPLYING");
|
||||
var liveScope = scope("OUTBOUND");
|
||||
liveScope.setLeaseOwner("active-worker");
|
||||
liveScope.setLeaseUntilUtc(utcNow().plusDays(1));
|
||||
when(tasks.selectById(1L)).thenReturn(original);
|
||||
when(locks.lockScope(10L)).thenReturn(liveScope);
|
||||
assertThatThrownBy(() -> service.retry(1L, "first-command", "new-operator", "offline retry"))
|
||||
.hasMessage("SELFOP_RETRY_LEASE_BUSY");
|
||||
assertThat(original.getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(original.getApplyState()).isEqualTo("APPLYING");
|
||||
assertThat(original.getNextRetryAtUtc()).isNull();
|
||||
verify(locks, never()).lockTask(anyLong());
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verify(tasks, never()).insert(any(SelfopSyncTask.class));
|
||||
verifyNoInteractions(accounts, runs, scopes);
|
||||
}
|
||||
|
||||
@Test void concurrentRetryAuditAppearingAfterScopeLockUsesCurrentReadAndDoesNotResetTask() {
|
||||
var original = task("RUNNING");
|
||||
original.setApplyState("APPLYING");
|
||||
var audit = task("SUCCEEDED");
|
||||
audit.setRequestHash(hash(List.of(1L, "raced-command", "offline retry")));
|
||||
when(tasks.selectById(1L)).thenReturn(original);
|
||||
when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND"));
|
||||
int[] reads = {0};
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> {
|
||||
QueryWrapper<SelfopSyncTask> query = call.getArgument(0);
|
||||
if (++reads[0] == 1) {
|
||||
assertThat(query.getSqlSegment()).doesNotContain("FOR UPDATE");
|
||||
return null;
|
||||
}
|
||||
assertThat(query.getSqlSegment()).endsWith("FOR UPDATE");
|
||||
return audit;
|
||||
});
|
||||
assertThat(service.retry(1L, "raced-command", "new-operator", "offline retry")).isSameAs(original);
|
||||
assertThat(reads[0]).isEqualTo(2);
|
||||
assertThat(original.getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(original.getApplyState()).isEqualTo("APPLYING");
|
||||
assertThat(original.getNextRetryAtUtc()).isNull();
|
||||
verify(locks).lockScope(10L);
|
||||
verify(locks, never()).lockTask(anyLong());
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verify(tasks, never()).insert(any(SelfopSyncTask.class));
|
||||
}
|
||||
|
||||
@Test void duplicateInsertUsesCurrentReadAndReturnsExistingTaskWithoutResettingIt() {
|
||||
var existing = task("RUNNING");
|
||||
existing.setApplyState("APPLYING");
|
||||
existing.setAttemptCount(8);
|
||||
var incoming = task("READY");
|
||||
int[] reads = {0};
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> {
|
||||
QueryWrapper<SelfopSyncTask> query = call.getArgument(0);
|
||||
if (++reads[0] == 1) return null;
|
||||
assertThat(query.getSqlSegment()).endsWith("FOR UPDATE");
|
||||
return existing;
|
||||
});
|
||||
when(tasks.insert(any(SelfopSyncTask.class))).thenThrow(new DuplicateKeyException("synthetic duplicate"));
|
||||
assertThat(service.create(incoming)).isSameAs(existing);
|
||||
assertThat(reads[0]).isEqualTo(2);
|
||||
assertThat(existing.getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(existing.getApplyState()).isEqualTo("APPLYING");
|
||||
assertThat(existing.getAttemptCount()).isEqualTo(8);
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verifyNoInteractions(locks, accounts, runs, scopes);
|
||||
}
|
||||
|
||||
@Test void createRefusesSameRequestKeyWithDifferentPayloadWithoutAllocatingOrWriting() {
|
||||
var existing = task("RUNNING");
|
||||
existing.setRequestHash("existing-hash");
|
||||
var incoming = task("READY");
|
||||
incoming.setRequestHash("different-hash");
|
||||
when(tasks.selectOne(any())).thenReturn(existing);
|
||||
assertThatThrownBy(() -> service.create(incoming)).hasMessage("SELFOP_IDEMPOTENCY_CONFLICT");
|
||||
assertThat(incoming.getAttemptCount()).isEqualTo(0);
|
||||
verify(tasks, never()).insert(any(SelfopSyncTask.class));
|
||||
verify(tasks, never()).updateById(any(SelfopSyncTask.class));
|
||||
verifyNoInteractions(locks, accounts, runs, scopes);
|
||||
}
|
||||
|
||||
@Test void lostLeaseFailCannotTouchCurrentRunOrTask() {
|
||||
doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease);
|
||||
assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease))
|
||||
.hasMessage("SELFOP_FENCE_REJECTED");
|
||||
verifyNoInteractions(locks, tasks, runs, scopes, accounts);
|
||||
}
|
||||
|
||||
@Test void oldRunFenceCannotMutateWithOtherwiseValidCurrentScopeLease() {
|
||||
var task = task("RUNNING");
|
||||
var oldRun = run("FETCHING");
|
||||
oldRun.setFenceToken(6L);
|
||||
when(locks.lockTask(1L)).thenReturn(task);
|
||||
when(locks.lockRun(20L)).thenReturn(oldRun);
|
||||
assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease))
|
||||
.hasMessage("SELFOP_RUN_LEASE_MISMATCH");
|
||||
verify(leases).verify(lease);
|
||||
assertThat(task.getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(oldRun.getStatus()).isEqualTo("FETCHING");
|
||||
assertThat(oldRun.getErrorCode()).isNull();
|
||||
verifyNoInteractions(tasks, runs, scopes, accounts);
|
||||
}
|
||||
|
||||
@Test void budgetYieldDoesNotBecomeFailedAttemptOrClearApplyBarrier() {
|
||||
var task = task("RUNNING");
|
||||
task.setAttemptCount(99);
|
||||
task.setApplyState("APPLYING");
|
||||
var run = run("FETCHING");
|
||||
when(locks.lockTask(1L)).thenReturn(task);
|
||||
when(locks.lockRun(20L)).thenReturn(run);
|
||||
service.yield(1L, 20L, lease);
|
||||
assertThat(run.getStatus()).isEqualTo("YIELDED");
|
||||
assertThat(run.getErrorCode()).isNull();
|
||||
assertThat(task.getStatus()).isEqualTo("RETRY_WAIT");
|
||||
assertThat(task.getAttemptCount()).isEqualTo(99);
|
||||
assertThat(task.getApplyState()).isEqualTo("APPLYING");
|
||||
assertThat(task.getNextRetryAtUtc()).isNotNull();
|
||||
verify(runs, never()).selectCount(any());
|
||||
verify(runs).updateById(run);
|
||||
verify(tasks).updateById(task);
|
||||
}
|
||||
|
||||
@Test void finiteFailureBudgetCountsFailuresInsteadOfManyYieldedAttempts() {
|
||||
config.setMaxAttempts(3);
|
||||
var task = task("RUNNING");
|
||||
task.setAttemptCount(99);
|
||||
task.setApplyState("APPLYING");
|
||||
var run = run("FETCHING");
|
||||
when(locks.lockTask(1L)).thenReturn(task);
|
||||
when(locks.lockRun(20L)).thenReturn(run);
|
||||
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
when(runs.selectCount(any())).thenAnswer(call -> {
|
||||
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SelfopSyncRun> query = call.getArgument(0);
|
||||
query.getSqlSegment();
|
||||
assertThat(query.getParamNameValuePairs()).containsValue("FAILED").doesNotContainValue("YIELDED");
|
||||
return 2L;
|
||||
});
|
||||
service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease);
|
||||
assertThat(task.getStatus()).isEqualTo("RETRY_WAIT");
|
||||
assertThat(task.getApplyState()).isEqualTo("BLOCKED");
|
||||
assertThat(run.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(run.getLastPhase()).isEqualTo("FETCHING");
|
||||
assertThat(run.getErrorCode()).isEqualTo("SOURCE_TEMPORARY");
|
||||
verify(runs).selectCount(any());
|
||||
}
|
||||
|
||||
@Test void exhaustedFiniteFailuresBecomeDeadAndKeepBlockedApplyState() {
|
||||
config.setMaxAttempts(3);
|
||||
var task = task("RUNNING");
|
||||
task.setApplyState("APPLYING");
|
||||
when(locks.lockTask(1L)).thenReturn(task);
|
||||
when(locks.lockRun(20L)).thenReturn(run("BUILDING"));
|
||||
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
when(runs.selectCount(any())).thenReturn(3L);
|
||||
service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease);
|
||||
assertThat(task.getStatus()).isEqualTo("DEAD");
|
||||
assertThat(task.getApplyState()).isEqualTo("BLOCKED");
|
||||
}
|
||||
|
||||
@Test void validationChildCannotEnterBusinessFailureOrManualRetryQueue() {
|
||||
var child=task("RUNNING");child.setExecutionPurpose("VALIDATION");child.setValidationRootTaskId(9L);
|
||||
var attempt=run("FETCHING");attempt.setExecutionPurpose("VALIDATION");
|
||||
when(locks.lockTask(1L)).thenReturn(child);when(locks.lockRun(20L)).thenReturn(attempt);
|
||||
when(tasks.selectById(1L)).thenReturn(child);when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND"));
|
||||
assertThatThrownBy(()->service.fail(1L,20L,"VALIDATION_STRUCTURE_REJECTED",true,lease))
|
||||
.hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED");
|
||||
assertThatThrownBy(()->service.retry(1L,"validation-retry","operator","offline retry"))
|
||||
.hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED");
|
||||
assertThat(child.getStatus()).isEqualTo("RUNNING");assertThat(child.getNextRetryAtUtc()).isNull();
|
||||
assertThat(attempt.getStatus()).isEqualTo("FETCHING");
|
||||
verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(tasks,never()).insert(any(SelfopSyncTask.class));
|
||||
verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(runs,never()).selectCount(any());
|
||||
}
|
||||
|
||||
private SelfopSyncTask task(String status) {
|
||||
var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001");
|
||||
task.setId(1L); task.setScopeId(10L); task.setSourceAccount("synthetic-account");
|
||||
task.setWarehouseCode("WH-A"); task.setTaskRole("WAREHOUSE_SYNC");
|
||||
task.setRequestKey("slot-1"); task.setRequestHash("request-hash"); task.setScopeHash("scope-hash");
|
||||
task.setQueryScopeJson("{\"warehouseCode\":\"WH-A\"}");
|
||||
task.setStatus(status); task.setApplyState("NOT_STARTED"); task.setAttemptCount(0);
|
||||
return task;
|
||||
}
|
||||
private SelfopSyncScope scope(String family) {
|
||||
var scope = new SelfopSyncScope();
|
||||
scope.setId(10L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("WH-A");
|
||||
scope.setDataFamily(family); scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\"}");
|
||||
scope.setBusinessTimezone("America/New_York"); scope.setSourceTimezone("UTC");
|
||||
scope.setStaleAfterSeconds(43200); scope.setPublishedRunId(90L);
|
||||
return scope;
|
||||
}
|
||||
private SelfopSyncRun run(String status) {
|
||||
var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account");
|
||||
run.setId(20L); run.setTaskId(1L); run.setScopeId(10L); run.setFenceToken(7L); run.setStatus(status);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.request.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** Offline callbacks model durable checkpoints, without pretending to test MySQL transaction rollback. */
|
||||
class SelfopValidationRunnerTest {
|
||||
private final LingxingAnalyticsClient client=mock(LingxingAnalyticsClient.class);
|
||||
private final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class);
|
||||
private final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
|
||||
private final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
|
||||
private final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class);
|
||||
private final SelfopLeaseService leases=mock(SelfopLeaseService.class);
|
||||
private final SelfopEgressService egress=mock(SelfopEgressService.class);
|
||||
private final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
|
||||
private final TransactionTemplate tx=mock(TransactionTemplate.class);
|
||||
private final SelfopLeaseService.Lease lease=new SelfopLeaseService.Lease(3,"owner",8,true);
|
||||
private SelfopSyncTask task;
|
||||
private SelfopSyncRun run;
|
||||
private SelfopValidationRunner runner;
|
||||
@BeforeEach void setup() {
|
||||
config.setCollectionMode("VALIDATION");config.setSourceAccount("demo");config.setExclusiveGrantId("test-grant");
|
||||
config.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());
|
||||
task=new SelfopSyncTask();task.setId(1L);task.setValidationRootTaskId(1L);task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION");
|
||||
task.setSourceAccount("demo");task.setDiscoveryScopeId(3L);task.setAttemptCount(1);task.setStatus("RUNNING");
|
||||
task.setQueryScopeJson("{\"startTime\":\"2026-10-12T08:00:00\",\"endTime\":\"2026-10-12T08:10:00\"}");
|
||||
task.setValidationBudgetJson(SelfopValidationBudget.freeze(utcNow()));
|
||||
run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setSourceAccount("demo");run.setExecutionPurpose("VALIDATION");run.setAttemptNo(1);run.setFenceToken(8L);run.setStatus("FETCHING");
|
||||
when(locks.lockTask(1)).thenReturn(task);when(locks.lockRun(2)).thenReturn(run);when(tasks.selectById(1L)).thenReturn(task);
|
||||
doAnswer(i->{Consumer<TransactionStatus> callback=i.getArgument(0);callback.accept(mock(TransactionStatus.class));return null;}).when(tx).executeWithoutResult(any());
|
||||
when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,0,0,List.of()));
|
||||
when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of()));
|
||||
when(client.inventory(any(SelfopRequestContext.class),anyString(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of()));
|
||||
runner=new SelfopValidationRunner(client,stages,tasks,runs,locks,leases,egress,config,tx);
|
||||
}
|
||||
private <T> LingxingAnalyticsClient.Page<T> page(int current,long total,int pages,List<T> records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);}
|
||||
private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);}
|
||||
private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00");}
|
||||
|
||||
@Test void truncatedTwoWarehouseSampleOnlyStagesAndDoesNotClaimCompleteCoverage() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO))));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("A","two",null))));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("B","one",BigDecimal.ONE))));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("B","two",BigDecimal.ONE))));
|
||||
runner.run(task,run,lease);
|
||||
assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus());assertNull(run.getPublishedAtUtc());assertNull(run.getCoveredUntilUtc());
|
||||
verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),eq(3),anyInt());
|
||||
var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(4)).insert(capture.capture());
|
||||
assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().equals("PRODUCT_STOCK")&&s.getObservationSequence()==null));
|
||||
assertTrue(capture.getAllValues().stream().anyMatch(s->"PENDING".equals(s.getValidationStatus())));
|
||||
verify(egress).finishSample(1L,"VALIDATION_SAMPLE_SUFFICIENT");
|
||||
verify(tasks,never()).updateById(any(SelfopSyncTask.class)); // quota root JSON cannot be overwritten by an earlier task snapshot
|
||||
}
|
||||
@Test void emptyCompletePagesStillEndSampleAndNeverBecomeFormalVersion() {
|
||||
runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus());
|
||||
assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision());verify(stages,never()).insert(any(SelfopSyncStage.class));
|
||||
assertNotNull(run.getProgressSnapshotJson());
|
||||
}
|
||||
@Test void requestBudgetStopIsSampleDoneNotYieldAndNoSubsequentRequests() {
|
||||
when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenThrow(new EgressRejectedException("VALIDATION_PAGE_LIMIT",true,null));
|
||||
runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());verify(egress).finishSample(1L,"VALIDATION_PAGE_LIMIT");
|
||||
verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),anyInt(),anyInt());
|
||||
}
|
||||
@Test void rateLimitFailsEntireRootAndDoesNotResumeOtherWarehouse() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenThrow(new AnalyticsClientException("LINGXING_WMS_RATE_LIMITED",true));
|
||||
runner.run(task,run,lease);assertEquals("FAILED",run.getStatus());verify(egress).failValidation(1L,"LINGXING_WMS_RATE_LIMITED");
|
||||
verify(client,never()).inventory(any(SelfopRequestContext.class),eq("B"),anyInt(),anyInt());
|
||||
}
|
||||
@Test void uniqueDetailsRetainSkuQuantitiesAndIdentityValidation() throws Exception {
|
||||
when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(summary("order"))));
|
||||
var detail=new SelfopPayloads.Order("demo","customer","A","order","source",100,null,"2026-10-12 08:02:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS")));
|
||||
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("order"))).thenReturn(detail);
|
||||
runner.run(task,run,lease);
|
||||
var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(2)).insert(capture.capture());
|
||||
var row=capture.getAllValues().stream().filter(s->"OUTBOUND_ORDER".equals(s.getObjectType())&&"SAMPLE_VALIDATED".equals(s.getValidationCode())).findFirst().orElseThrow();
|
||||
assertEquals(BigDecimal.ONE,read(row.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity());
|
||||
assertNull(row.getObservationSequence());assertEquals("SAMPLE_DONE",run.getStatus());
|
||||
assertEquals("PENDING",row.getValidationStatus());
|
||||
assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().length()<=16&&
|
||||
(!"OUTBOUND_ORDER".equals(s.getObjectType())||!"VALID".equals(s.getValidationStatus())||s.getObservationSequence()!=null)));
|
||||
try(var schema=getClass().getResourceAsStream("/db/lastleg.sql")) {
|
||||
assertNotNull(schema);
|
||||
String sql=new String(schema.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertTrue(sql.contains("object_type VARCHAR(16)"));
|
||||
assertTrue(sql.contains("object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL"));
|
||||
}
|
||||
}
|
||||
@Test void restartUsesPersistedSampleCursorWithoutRepeatingCompletedPagesOrDetails() {
|
||||
var saved=new SelfopValidationRunner.Sample();saved.setWarehousesRead(true);saved.setDirectoryRead(true);saved.setDetailIndex(1);
|
||||
saved.getOrders().add(summary("order"));saved.getInventoryFinished().add("A");saved.getInventoryNextPage().put("A",3);
|
||||
task.setRoutingManifestJson(json(saved));runner.run(task,run,lease);
|
||||
verify(client,never()).warehouses(any(SelfopRequestContext.class),anyInt(),anyInt());
|
||||
verify(client,never()).detail(any(SelfopRequestContext.class),anyString(),anyString(),anyString());
|
||||
verify(client,never()).inventory(any(SelfopRequestContext.class),eq("A"),anyInt(),anyInt());
|
||||
verify(client).inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10));
|
||||
assertEquals("SAMPLE_DONE",run.getStatus());
|
||||
}
|
||||
@Test void wrongPurposeCannotCallAnyClient() {
|
||||
task.setExecutionPurpose("BUSINESS");assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(client,egress);
|
||||
}
|
||||
@Test void malformedPersistedSampleTerminatesRootBeforeAnyProviderRequest() {
|
||||
task.setRoutingManifestJson("{invalid");
|
||||
runner.run(task,run,lease);
|
||||
assertEquals("FAILED",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus());
|
||||
assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision());
|
||||
verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED");
|
||||
verifyNoInteractions(client,stages);
|
||||
verify(tasks).update(isNull(),argThat((UpdateWrapper<SelfopSyncTask> write)->
|
||||
write.getSqlSet().contains("next_retry_at_utc")
|
||||
&& write.getParamNameValuePairs().containsValue("DEAD")
|
||||
&& write.getParamNameValuePairs().containsValue(null)));
|
||||
}
|
||||
@Test void executionFailureOnChildTerminatesItsOriginalRootAndClearsChildRetry() {
|
||||
task.setValidationRootTaskId(9L);
|
||||
runner.fail(task,run,lease,"VALIDATION_STRUCTURE_REJECTED");
|
||||
assertEquals("FAILED",run.getStatus());assertNull(run.getPublishedAtUtc());
|
||||
verify(egress).failValidation(9L,"VALIDATION_STRUCTURE_REJECTED");
|
||||
verify(tasks).update(isNull(),argThat((UpdateWrapper<SelfopSyncTask> write)->
|
||||
write.getSqlSet().contains("next_retry_at_utc")
|
||||
&& write.getParamNameValuePairs().containsValue("DEAD")
|
||||
&& write.getParamNameValuePairs().containsValue(null)));
|
||||
verifyNoInteractions(client,stages);
|
||||
}
|
||||
@Test void oldExecutorCannotMarkNewAttemptFailed() {
|
||||
doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).renew(lease);
|
||||
assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(egress);verifyNoInteractions(client);
|
||||
}
|
||||
@Test void warehouseEvidenceUsesRunDimensionsAndOnlyDeclaredStageTypesAreWritten() {
|
||||
var warehouse=new LingxingAnalyticsClient.Warehouse("A","Warehouse",0,0,"America/New_York");
|
||||
when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,11,2,List.of(warehouse)));
|
||||
runner.run(task,run,lease);
|
||||
var dimensions=read(run.getObservedDimensionsJson(),Map.class);
|
||||
assertEquals(true,dimensions.get("truncated"));assertTrue(dimensions.containsKey("validationWarehouseSamples"));
|
||||
verify(stages,never()).insert(any(SelfopSyncStage.class));assertEquals("SAMPLE_DONE",run.getStatus());
|
||||
}
|
||||
@Test void overlongSkuCannotBeAcceptedAsStructurallyValidStock() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(stock("A","x".repeat(129),BigDecimal.ONE))));
|
||||
runner.run(task,run,lease);assertEquals("FAILED",run.getStatus());
|
||||
verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED");verify(stages,never()).insert(any(SelfopSyncStage.class));
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopOutboundDiscoveryScopeMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SyncControllerTest {
|
||||
private static final String SYNC = "platform:self-operated-analytics:sync";
|
||||
private final SelfopCommandService.Command command = new SelfopCommandService.Command(
|
||||
"synthetic-command", "WH-A", "INVENTORY", null, null, null, null, null, "offline test");
|
||||
|
||||
@Test void anonymousAndCustomerCannotCreatePlatformTasksEvenWithSyncPermission() {
|
||||
var commands = mock(SelfopCommandService.class);
|
||||
var controller = new SyncController(commands);
|
||||
assertForbidden(() -> controller.request(null, command));
|
||||
assertForbidden(() -> controller.request(user(100L, Set.of(SYNC)), command));
|
||||
verifyNoInteractions(commands);
|
||||
}
|
||||
|
||||
@Test void platformReadPermissionDoesNotAuthorizeManualSync() {
|
||||
var commands = mock(SelfopCommandService.class);
|
||||
var controller = new SyncController(commands);
|
||||
assertForbidden(() -> controller.request(user(0L, Set.of("platform:self-operated-analytics:list")), command));
|
||||
assertForbidden(() -> controller.request(user(0L, Set.of()), command));
|
||||
verifyNoInteractions(commands);
|
||||
}
|
||||
|
||||
@Test void platformScopeFromClientAppAndImpersonatedUserAreDenied() {
|
||||
var commands = mock(SelfopCommandService.class);
|
||||
var controller = new SyncController(commands);
|
||||
var client = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "CLIENT", "WEB", Set.of(SYNC));
|
||||
var impersonated = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", Set.of(SYNC),
|
||||
true, 77L, 2L, false);
|
||||
assertForbidden(() -> controller.request(client, command));
|
||||
assertForbidden(() -> controller.request(impersonated, command));
|
||||
verifyNoInteractions(commands);
|
||||
}
|
||||
|
||||
@Test void authorizedPlatformCommandReturnsAcceptedWithoutPerformingCollection() {
|
||||
var config = new SelfopAnalyticsProperties();
|
||||
assertThat(config.getCollectionMode()).isEqualTo("OFF");
|
||||
assertThat(config.isExecutionEnabled()).isFalse();
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account");
|
||||
config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse());
|
||||
var tasks = mock(SelfopTaskService.class);
|
||||
var scopes = mock(SelfopSyncScopeMapper.class);
|
||||
var accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
|
||||
var scope = new SelfopSyncScope();
|
||||
scope.setId(10L); scope.setScopeHash("synthetic-scope-hash");
|
||||
scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}");
|
||||
when(scopes.selectOne(any())).thenReturn(scope);
|
||||
when(tasks.create(any(SelfopSyncTask.class))).thenAnswer(call -> {
|
||||
SelfopSyncTask task = call.getArgument(0); task.setId(101L); task.setTaskNo("LXCOL-20261012-00000101"); return task;
|
||||
});
|
||||
var taskRows = mock(SelfopSyncTaskMapper.class);
|
||||
var commands = new SelfopCommandService(tasks, scopes, accounts, config, taskRows);
|
||||
var response = new SyncController(commands).request(user(0L, Set.of(SYNC)), command);
|
||||
assertThat(response.successful()).isTrue();
|
||||
assertThat(response.data().taskNo()).isEqualTo("LXCOL-20261012-00000101");
|
||||
assertThat(response.data().status()).isEqualTo("ACCEPTED");
|
||||
assertThat(response.data().warehouseCode()).isEqualTo("WH-A");
|
||||
assertThat(config.isExecutionEnabled()).isTrue();
|
||||
assertThat(config.isSalesPublicationEnabled()).isFalse();
|
||||
var captured = ArgumentCaptor.forClass(SelfopSyncTask.class);
|
||||
verify(tasks).create(captured.capture());
|
||||
assertThat(captured.getValue().getRequestedBy()).isEqualTo("42");
|
||||
assertThat(captured.getValue().getRequestKey()).isEqualTo("manual:synthetic-command");
|
||||
assertThat(captured.getValue().getTaskRole()).isEqualTo("WAREHOUSE_SYNC");
|
||||
assertThat(captured.getValue().getWarehouseCode()).isEqualTo("WH-A");
|
||||
verify(tasks).initialize();
|
||||
verifyNoInteractions(accounts, taskRows);
|
||||
}
|
||||
|
||||
@Test void endpointDeclaresIndependentSyncAuthority() throws Exception {
|
||||
var method = SyncController.class.getMethod("request", AuthenticatedUser.class, SelfopCommandService.Command.class);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + SYNC + "')");
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(Long customerScope, Set<String> permissions) {
|
||||
return new AuthenticatedUser(42L, customerScope, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable request) {
|
||||
assertThatThrownBy(request).isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(e -> assertThat(((ResponseStatusException)e).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN));
|
||||
}
|
||||
}
|
||||
+41
-8
@@ -1,7 +1,9 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
@@ -11,11 +13,11 @@ import static org.mockito.Mockito.*;
|
||||
class ReturnOrderReferenceResolverTest {
|
||||
@Test
|
||||
void directoryFailureBlocksNewSnapshotButDoesNotHideHistoricalOrders() {
|
||||
var customers = mock(FleetCustomerDirectory.class);
|
||||
var customers = mock(ReturnCustomerReferenceClient.class);
|
||||
var warehouses = mock(WarehouseResourceGateway.class);
|
||||
when(customers.find("1")).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE));
|
||||
when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE));
|
||||
when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE"));
|
||||
var resolver = new ReturnOrderReferenceResolver(customers, warehouses);
|
||||
var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token");
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> resolver.populate(List.of(order())));
|
||||
var historical = order();
|
||||
resolver.populateForRead(List.of(historical));
|
||||
@@ -24,24 +26,55 @@ class ReturnOrderReferenceResolverTest {
|
||||
|
||||
@Test
|
||||
void resolvesMissingReturnReferencesOncePerCustomerAndWarehouseAndPreservesSnapshots() {
|
||||
var customers = mock(FleetCustomerDirectory.class);
|
||||
var customers = mock(ReturnCustomerReferenceClient.class);
|
||||
var warehouses = mock(WarehouseResourceGateway.class);
|
||||
when(customers.find("1")).thenReturn(new FleetCustomerDirectory.Customer("1", "C1", "测试货主", null, null));
|
||||
when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>(
|
||||
List.of(new ReturnCustomerReferenceClient.Customer(1L, "C1", "测试货主", "ACTIVE", null, null)), 1, 30, 1, 1)));
|
||||
when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE"));
|
||||
LastlegOrder first = order(), second = order(), frozen = order();
|
||||
frozen.setCustomerName("原货主快照"); frozen.setOverseasWarehouseCode("OLD"); frozen.setOverseasWarehouseName("原仓库快照");
|
||||
|
||||
new ReturnOrderReferenceResolver(customers, warehouses).populate(List.of(first, second, frozen));
|
||||
new ReturnOrderReferenceResolver(customers, warehouses, "test-token").populate(List.of(first, second, frozen));
|
||||
|
||||
assertEquals("测试货主", first.getCustomerName());
|
||||
assertEquals("USLAX01", second.getOverseasWarehouseCode());
|
||||
assertEquals("洛杉矶仓", second.getOverseasWarehouseName());
|
||||
assertEquals("原货主快照", frozen.getCustomerName());
|
||||
assertEquals("原仓库快照", frozen.getOverseasWarehouseName());
|
||||
verify(customers, times(1)).find("1");
|
||||
verify(customers, times(1)).customers("test-token", 1, 30, null, "ACTIVE", 1L);
|
||||
verify(warehouses, times(1)).requireActiveSharedWarehouse(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignOrInactiveCustomerCannotSupplyReturnSnapshot() {
|
||||
var customers = mock(ReturnCustomerReferenceClient.class);
|
||||
var warehouses = mock(WarehouseResourceGateway.class);
|
||||
when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>(List.of(
|
||||
new ReturnCustomerReferenceClient.Customer(2L, "C2", "其他客户", "ACTIVE", null, null),
|
||||
new ReturnCustomerReferenceClient.Customer(1L, "C1", "停用客户", "DISABLED", null, null)), 1, 30, 2, 1)));
|
||||
var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token");
|
||||
var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> resolver.populate(List.of(order())));
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
verifyNoInteractions(warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unexpectedClientFailureDoesNotHideHistoricalReturnAndKeepsMissingNameUnknown() {
|
||||
var customers = mock(ReturnCustomerReferenceClient.class);
|
||||
var warehouses = mock(WarehouseResourceGateway.class);
|
||||
when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new IllegalStateException("transport failure"));
|
||||
when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE"));
|
||||
var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token");
|
||||
var historical = order();
|
||||
resolver.populateForRead(List.of(historical));
|
||||
org.junit.jupiter.api.Assertions.assertNull(historical.getCustomerName());
|
||||
assertEquals("洛杉矶仓", historical.getOverseasWarehouseName());
|
||||
var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> resolver.populate(List.of(order())));
|
||||
assertEquals(503, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
private LastlegOrder order() {
|
||||
var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL");
|
||||
order.setCustomerId("1"); order.setOverseasWarehouseId("10"); return order;
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
|
||||
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
|
||||
import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.*;
|
||||
@@ -35,7 +35,7 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class WarehouseTransferReceiptCoordinatorTest {
|
||||
JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator;
|
||||
WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog;
|
||||
WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; TransferWarehouseCatalogClient catalog;
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger();
|
||||
AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean();
|
||||
@@ -73,7 +73,7 @@ class WarehouseTransferReceiptCoordinatorTest {
|
||||
ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class));
|
||||
ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds));
|
||||
ReflectionTestUtils.setField(coordinator, "objectMapper", json);
|
||||
warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class);
|
||||
warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(TransferWarehouseCatalogClient.class);
|
||||
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory);
|
||||
ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token");
|
||||
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
|
||||
@@ -81,7 +81,7 @@ class WarehouseTransferReceiptCoordinatorTest {
|
||||
when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference(
|
||||
call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE"));
|
||||
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
|
||||
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
|
||||
new TransferWarehouseCatalogClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
|
||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
||||
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of())));
|
||||
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
|
||||
|
||||
Reference in New Issue
Block a user