fix(selfop): persist bounded next-root quantity diagnostics

This commit is contained in:
2026-10-14 04:59:05 +08:00
parent 765b6e6208
commit 913b9e9262
17 changed files with 1142 additions and 914 deletions
@@ -1,47 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.springframework.mock.web.MockHttpServletRequest;
class QuantityDiagnosticControllerTest {
@Test void httpStatusContractSurvivesSharedBusinessErrorAdvice() throws Exception {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build();
String path = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders";
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.contentType("application/json").content("{}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized());
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.header("X-Internal-Service-Token", "test-token").contentType("application/json").content("{\"address\":1}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest());
}
@Test void rejectsInvalidTokenBeforeParsingOrStorage() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept(null, new byte[0])).hasMessageContaining("401");
assertThatThrownBy(() -> controller.accept("wrong", new byte[0])).hasMessageContaining("401");
verifyNoInteractions(service);
}
@Test void missingTokenConfigurationFailsClosed() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard(""), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept("any", new byte[0])).hasMessageContaining("503");
verifyNoInteractions(service);
}
@Test void rejectsNonWhitelistedFieldsTrailingDocumentsAndOversizedPayload() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept("test-token", "{\"address\":\"not allowed\"}".getBytes(StandardCharsets.UTF_8)))
.hasMessageContaining("DIAGNOSTIC_JSON_INVALID");
assertThatThrownBy(() -> controller.accept("test-token", "{} {}".getBytes(StandardCharsets.UTF_8)))
.hasMessageContaining("DIAGNOSTIC_JSON_INVALID");
assertThatThrownBy(() -> controller.accept("test-token", new byte[262145])).hasMessageContaining("DIAGNOSTIC_REQUEST_TOO_LARGE");
verifyNoInteractions(service);
}
@Test void everyRouteRequiresInternalTokenBeforeParsing()throws Exception{
var service=mock(QuantityDiagnosticService.class);var controller=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build();
for(String action:new String[]{"arm","status","observe","stop"})mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/internal/lastleg/self-operated-analytics/quantity-diagnostics/"+action).contentType("application/json").content("{}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized());
verifyNoInteractions(service);
}
@Test void strictWhitelistAndBoundedBody(){
var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
for(String body:new String[]{"{\"address\":1}","{} {}"}){var request=new MockHttpServletRequest();request.setContent(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("JSON_INVALID");}
var request=new MockHttpServletRequest();request.setContent(new byte[262145]);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");verifyNoInteractions(service);
}
@Test void streamIsLimitedEvenWithoutContentLength()throws Exception{
var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
var request=mock(jakarta.servlet.http.HttpServletRequest.class);when(request.getContentLengthLong()).thenReturn(-1L);
var count=new java.util.concurrent.atomic.AtomicInteger();var stream=new jakarta.servlet.ServletInputStream(){public int read(){count.incrementAndGet();return 32;}public boolean isFinished(){return false;}public boolean isReady(){return true;}public void setReadListener(jakarta.servlet.ReadListener listener){}};
when(request.getInputStream()).thenReturn(stream);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");assertThat(count.get()).isEqualTo(262145);verifyNoInteractions(service);
}
}
@@ -1,5 +1,4 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*;
import static org.assertj.core.api.Assertions.*;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -12,158 +11,133 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.jdbc.datasource.*;
class QuantityDiagnosticServiceTest {
private final Instant start = Instant.parse("2026-10-13T15:00:00Z");
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private JdbcTemplate jdbc;
private DataSourceTransactionManager manager;
private QuantityDiagnosticService service;
private QuantityDiagnosticProperties properties;
@BeforeEach void prepare() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000", "sa", "");
jdbc = new JdbcTemplate(ds); manager = new DataSourceTransactionManager(ds);
// Exercise the dedicated production DDL, omitting only MySQL table options/comments.
String ddl;
try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) {
ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
ddl = ddl.replaceAll("(?m)^--.*$", "").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "")
.replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", "");
for (String statement : ddl.split(";")) if (!statement.isBlank()) jdbc.execute(statement);
properties = config(start.plusSeconds(1800), true);
service = instance(properties, start.plusSeconds(10));
}
private QuantityDiagnosticProperties config(Instant expires, boolean enabled) {
return new QuantityDiagnosticProperties(enabled, "diag-1", "collection-1", "root-1", "account-1",
List.of("warehouse-1"), start, expires);
}
private QuantityDiagnosticService instance(QuantityDiagnosticProperties p, Instant now) {
return new QuantityDiagnosticService(jdbc, manager, json, p, Clock.fixed(now, ZoneOffset.UTC));
}
private Sku sku(String code, String list, String forecast, String actual) {
return new Sku(code, list == null ? null : new BigDecimal(list), forecast == null ? null : new BigDecimal(forecast),
actual == null ? null : new BigDecimal(actual), true, true);
}
private Order order(String no, Sku... skus) {
return new Order("diag-1", "collection-1", "root-1", "account-1", "warehouse-1", "owner-1", no,
new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(1), "list-1", true),
new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(2), "detail-1", true), List.of(skus));
}
@Test void firstDifferencePersistsEvidenceAndStopsFurtherOrdersIncludingAfterRestart() {
var request = order("order-1", sku("sku-1", "10", "10", "8"));
var result = service.accept(request);
assertThat(result.outcome()).isEqualTo("ACCEPTED");
assertThat(result.comparison()).isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE);
assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE");
service = instance(properties, start.plusSeconds(20));
assertThat(service.accept(request).outcome()).isEqualTo("DUPLICATE");
assertThat(service.accept(order("order-2", sku("sku-1", "1", "1", "1"))).outcome()).isEqualTo("STOPPED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1);
}
@Test void listMatchingActualStillRecordsForecastDifference() {
assertThat(service.accept(order("order-1", sku("sku", "8", "10", "8"))).comparison())
.isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE);
}
@Test void idempotencyCanonicalizesDecimalsAndRowOrderButRejectsChangedEvidence() {
service.accept(order("order-1", sku("b", "1.0", "1", "1"), sku("a", "2", "2", "2")));
var result = service.accept(order("order-1", sku("a", "2.00", "2", "2"), sku("b", "1", "1", "1")));
assertThat(result.outcome()).isEqualTo("DUPLICATE");
assertThat(result.acceptedOrders()).isEqualTo(1);
assertThatThrownBy(() -> service.accept(order("order-1", sku("a", "3", "3", "3"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test void skuReplacementCannotBeHiddenByEqualTotalsAndDifferenceStillStops() {
var result = service.accept(order("order-1",
new Sku("old", BigDecimal.TEN, null, null, true, false),
new Sku("new", null, BigDecimal.TEN, BigDecimal.valueOf(8), false, true)));
assertThat(result.comparison()).isEqualTo(Comparison.SKU_SET_MISMATCH);
assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE");
}
@Test void missingQuantityRemainsUnknown() {
var result = service.accept(order("order-1", sku("sku", null, "1", "1")));
assertThat(result.comparison()).isEqualTo(Comparison.QUANTITY_UNKNOWN);
assertThat(result.stopReason()).isNull();
}
@Test void duplicateSkusAndCrossGenerationAreRejectedWithoutWrites() {
assertThatThrownBy(() -> service.accept(order("order-1", sku("sku", "1", "1", "1"), sku("sku", "1", "1", "1"))))
.hasMessageContaining("DIAGNOSTIC_DUPLICATE_SKU");
Order r = order("order-1", sku("sku", "1", "1", "1"));
assertThatThrownBy(() -> service.accept(new Order(r.diagnosticSessionId(), "other-generation", r.rootTaskId(), r.sourceAccount(),
r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), r.skus())))
.hasMessageContaining("DIAGNOSTIC_SCOPE_MISMATCH");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero();
}
@Test void expiredAndReconfiguredSessionsCannotResetCounters() {
var r = order("order-1", sku("sku", "1", "1", "1")); service.accept(r);
var expired = instance(properties, start.plusSeconds(1800));
assertThat(expired.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("EXPIRED");
assertThat(expired.accept(r).outcome()).isEqualTo("DUPLICATE");
assertThatThrownBy(() -> instance(config(start.plusSeconds(1799), true), start.plusSeconds(20)).accept(r))
.hasMessageContaining("DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED");
}
@Test void disabledRequiresNoTablesOrValidSession() {
jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_order");
jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_session");
assertThat(instance(config(start.plusSeconds(1800), false), start).accept(null).stopReason()).isEqualTo("DISABLED");
}
@Test void concurrentRequestsCannotExceedHundredOrders() throws Exception {
var pool = Executors.newFixedThreadPool(4);
try {
List<Callable<Receipt>> jobs = new ArrayList<>();
for (int i = 0; i < 104; i++) { final int n = i; jobs.add(() -> service.accept(order("order-" + n, sku("sku", "1", "1", "1")))); }
var results = pool.invokeAll(jobs);
int accepted = 0;
for (var result : results) if (result.get().outcome().equals("ACCEPTED")) accepted++;
assertThat(accepted).isEqualTo(100);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(100);
assertThat(instance(properties, start.plusSeconds(20)).accept(order("after-restart", sku("sku", "1", "1", "1"))).stopReason())
.isEqualTo("LIMIT_REACHED");
} finally { pool.shutdownNow(); }
}
@Test void rowLimitAndFailedOrderInsertDoNotPartiallyAdvanceCounters() {
service.accept(order("order-1", sku("sku", "1", "1", "1")));
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000");
assertThat(service.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("LIMIT_REACHED");
assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isEqualTo(1);
}
@Test void byteLimitCannotBeResetByRestartOrPartiallySaveOrder() {
service.accept(order("order-1", sku("sku", "1", "1", "1")));
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_bytes=2097150");
var receipt = instance(properties, start.plusSeconds(20)).accept(order("order-2", sku("sku", "1", "1", "1")));
assertThat(receipt.stopReason()).isEqualTo("LIMIT_REACHED");
assertThat(receipt.acceptedBytes()).isEqualTo(2097150);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1);
}
@Test void dedicatedDdlHasChineseCommentsOnEveryTableAndOrdinaryColumn() throws Exception {
String ddl;
try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) {
ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
var fields = java.util.regex.Pattern.compile("(?m)^ [a-z][a-z_]+ (?:VARCHAR|CHAR|INT|BIGINT|JSON)\\b[^\\n]*").matcher(ddl);
int count = 0;
while (fields.find()) {
assertThat(fields.group()).matches("(?s).*COMMENT '[^']*[\\u4e00-\\u9fff][^']*'.*"); count++;
}
assertThat(count).isEqualTo(23);
assertThat(java.util.regex.Pattern.compile("COMMENT='[^']*[\\u4e00-\\u9fff][^']*';").matcher(ddl).results().count()).isEqualTo(2);
}
@Test void storageFailureRollsBackSessionCounterAndSessionCreation() {
jdbc.execute("ALTER TABLE ll_selfop_quantity_diagnostic_order ADD CONSTRAINT reject_test_order CHECK(order_no <> 'bad-order')");
assertThatThrownBy(() -> service.accept(order("bad-order", sku("sku", "1", "1", "1"))))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero();
}
@Test void cancellationAndMissingOutboundTimeRemainEvidenceOnly() {
var r = order("cancelled", sku("sku", "1", "1", "1"));
var cancelled = new Observation("cancelled", null, start.plusSeconds(2), "detail-cancel", true);
var result = service.accept(new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(),
r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), cancelled, r.skus()));
assertThat(result.comparison()).isEqualTo(Comparison.ALL_EQUAL);
assertThat(result.acceptedOrders()).isEqualTo(1);
}
final Instant start=Instant.parse("2026-10-14T00:00:00Z");
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
JdbcTemplate jdbc;DataSourceTransactionManager manager;QuantityDiagnosticService service;MutableClock clock;
final QuantityDiagnosticProperties config=new QuantityDiagnosticProperties(true,"account",List.of("W1","W2"));
@BeforeEach void setup()throws Exception{
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000","sa","");jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);clock=new MutableClock(start);
String ddl;try(var in=new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()){ddl=new String(in.readAllBytes(),StandardCharsets.UTF_8);}
ddl=ddl.replaceAll("(?m)^--.*$","").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "").replace("evidence_json JSON", "evidence_json LONGTEXT").replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");for(String sql:ddl.split(";"))if(!sql.isBlank())jdbc.execute(sql);
jdbc.execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,source_account VARCHAR(64),execution_purpose VARCHAR(32),upstream_task_id BIGINT,validation_root_task_id BIGINT,requested_by VARCHAR(64),scope_hash VARCHAR(64),request_key VARCHAR(128),task_role VARCHAR(32),task_type VARCHAR(32),discovery_scope_id BIGINT,query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,query_scope_json VARCHAR(2000),status VARCHAR(32),attempt_count INT DEFAULT 0)");
for(long id=1;id<=3;id++)jdbc.update("INSERT INTO ll_selfop_sync_task VALUES(?,?,?,NULL,NULL,?,?,?,?,?,?,?, ?,?,?,0)",id,"account","BUSINESS","SELFOP_CONTINUOUS_PLANNER","a".repeat(64),"continuous:outbound:2026-10-10:"+"a".repeat(12),"ACCOUNT_DISCOVERY","BACKFILL",1L,LocalDateTime.parse("2026-10-09T16:00:00"),LocalDateTime.parse("2026-10-10T16:00:00"),"{\"startTime\":\"2026-10-10T00:00\",\"endTime\":\"2026-10-11T00:00\"}",id==1?"RUNNING":"READY");
service=instance();service.arm(arm("diag"));service.status(new Status("account","producer",true,false));clock.now=start.plusSeconds(10);
}
QuantityDiagnosticService instance(){return new QuantityDiagnosticService(jdbc,manager,json,config,clock);}
Arm arm(String id){return new Arm(id,"account",List.of("W1","W2"),"*","1");}
Event event(String root,String order,String side,BigDecimal list,BigDecimal forecast,BigDecimal actual){return new Event("diag","account","producer",root,"selfop-"+root,"LIST".equals(side)?"OUTBOUND_QUERY":"OUTBOUND_DETAIL","W1","owner",order,side,new Observation("100",null,clock.instant(),"attempt",true),List.of(new Sku("SKU",list,forecast,actual)));}
Event list(String order){return event("2",order,"LIST",BigDecimal.TEN,null,null);}
Event detail(String order){return event("2",order,"DETAIL",null,BigDecimal.TEN,BigDecimal.TEN);}
View status(){return service.status(new Status("account","producer",false,false));}
@Test void explicitArmIsIdempotentAndBindsOnlyNextEligibleList(){
assertThat(service.arm(arm("diag")).startsAt()).isEqualTo(start);
assertThat(service.observe(detail("early")).outcome()).isEqualTo("IGNORED");
assertThat(service.observe(event("1","old","LIST",BigDecimal.ONE,null,null)).outcome()).isEqualTo("IGNORED");
Event locate=list("locate");assertThatThrownBy(()->service.observe(new Event(locate.diagnosticSessionId(),locate.sourceAccount(),locate.producerId(),locate.rootTaskId(),locate.collectionSessionId(),"OUTBOUND_LOCATE",locate.warehouseCode(),locate.ownerCode(),locate.orderNo(),locate.side(),locate.observation(),locate.skus()))).hasMessageContaining("OPERATION");
View bound=service.observe(list("a")).session();assertThat(bound.rootTaskId()).isEqualTo("2");assertThat(bound.expiresAt()).isEqualTo(clock.instant().plusSeconds(14400));
assertThatThrownBy(()->service.observe(event("3","other","LIST",BigDecimal.ONE,null,null))).hasMessageContaining("ROOT_FROZEN");
assertThatThrownBy(()->service.arm(arm("second"))).hasMessageContaining("ALREADY_ARMED");
}
@Test void durablePendingSurvivesReceiverRestartAndPreservesOriginalAttempt(){
Event original=list("a");service.observe(original);service=instance();clock.now=start.plusSeconds(5000);
assertThat(service.observe(original).outcome()).isEqualTo("DUPLICATE");
var result=service.observe(detail("a"));assertThat(result.comparison()).isEqualTo("ALL_EQUAL");assertThat(result.session().candidates()).isEqualTo(1);
String payload=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class);assertThat(payload).contains("attempt").contains("00:00:10Z");
}
@Test void anyKnownInequalityStopsIncludingListDifferenceAndTerminalReplay(){
Event l=list("a");service.observe(l);Event d=event("2","a","DETAIL",null,BigDecimal.ONE,BigDecimal.ONE);
var result=service.observe(d);assertThat(result.comparison()).isEqualTo("QUANTITY_DIFFERENCE");assertThat(result.session().state()).isEqualTo("TERMINAL");
assertThat(instance().observe(d).outcome()).isEqualTo("DUPLICATE");assertThat(service.observe(list("b")).outcome()).isEqualTo("STOPPED");
}
@Test void unknownListStopsAndRetainsEvidence(){
var receipt=service.observe(event("2","a","LIST",null,null,null));assertThat(receipt.comparison()).isEqualTo("INCONCLUSIVE");assertThat(receipt.session().stopReason()).isEqualTo("INCONCLUSIVE");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1);
}
@Test void missingSkuAndUnknownDetailStopAtomically(){
service.observe(list("a"));Event d=detail("a");var mismatch=new Event(d.diagnosticSessionId(),d.sourceAccount(),d.producerId(),d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),List.of(new Sku("OTHER",null,null,BigDecimal.TEN)));
assertThat(service.observe(mismatch).comparison()).isEqualTo("INCONCLUSIVE");assertThat(status().state()).isEqualTo("TERMINAL");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order",Integer.class)).isEqualTo(1);
}
@Test void lateCallbackCannotBindEvenWhenObservationWasBeforeDeadline(){
Event e=list("a");clock.now=start.plusSeconds(21600);assertThat(service.observe(e).session().stopReason()).isEqualTo("BIND_TIMEOUT");assertThat(status().rootTaskId()).isNull();
}
@Test void latestPossibleBindStillCannotExtendAbsoluteTenHours(){
clock.now=start.plusSeconds(21599);assertThat(service.observe(list("a")).session().expiresAt()).isEqualTo(start.plusSeconds(35999));
clock.now=start.plusSeconds(36000);assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE");
}
@Test void rootEndDrainsOnlyAcceptedCandidatesAndNeverExtendsDeadline(){
service.observe(list("a"));Instant expires=status().expiresAt();jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2");
assertThat(status().state()).isEqualTo("DRAINING");assertThat(service.observe(list("new")).outcome()).isEqualTo("IGNORED");
assertThat(service.observe(detail("a")).comparison()).isEqualTo("ALL_EQUAL");
View done=service.status(new Status("account","producer",false,true));assertThat(done.stopReason()).isEqualTo("ROOT_ENDED_INSUFFICIENT");assertThat(done.expiresAt()).isEqualTo(expires);
}
@Test void producerRestartMarksLostTailUncertainButRestoresPending(){
service.observe(list("a"));View recovered=service.status(new Status("account","new-producer",true,false));assertThat(recovered.deliveryUncertain()).isTrue();assertThat(recovered.selected()).hasSize(1);
Event d=detail("a");var resumed=new Event(d.diagnosticSessionId(),d.sourceAccount(),"new-producer",d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),d.skus());assertThat(service.observe(resumed).comparison()).isEqualTo("ALL_EQUAL");
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2");assertThat(service.status(new Status("account","new-producer",false,true)).stopReason()).isEqualTo("INCOMPLETE");
}
@Test void candidatesAreCumulativeAndNeverReplaced(){
for(int i=0;i<100;i++)service.observe(list("o"+i));assertThat(service.observe(list("extra")).outcome()).isEqualTo("IGNORED");
for(int i=0;i<100;i++)service.observe(detail("o"+i));View done=status();assertThat(done.candidates()).isEqualTo(100);assertThat(done.pairedOrders()).isEqualTo(100);assertThat(done.retainedRows()).isEqualTo(200);assertThat(done.stopReason()).isEqualTo("COMPLETED");
}
@Test void changedAttemptCannotOverwriteFirstSide(){
Event a=list("a");service.observe(a);clock.now=start.plusSeconds(20);assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("OBSERVATION_CONFLICT");assertThat(status().candidates()).isEqualTo(1);
}
@Test void concurrentReceiversSerializeSameCandidate()throws Exception{
var other=instance();Event e=list("a");var executor=Executors.newFixedThreadPool(2);try{var one=executor.submit(()->service.observe(e));var two=executor.submit(()->other.observe(e));assertThat(List.of(one.get().outcome(),two.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","DUPLICATE");}finally{executor.shutdownNow();}assertThat(status().candidates()).isEqualTo(1);
}
@Test void businessRootPolicyIsAuthoritative(){
jdbc.update("UPDATE ll_selfop_sync_task SET requested_by='manual' WHERE id=2");assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("ROOT_REJECTED");assertThat(status().rootTaskId()).isNull();
}
@Test void delayedFirstObservationDoesNotExtendSamplingWindow(){
Event first=list("a");clock.now=start.plusSeconds(5000);View bound=service.observe(first).session();assertThat(bound.startsAt()).isEqualTo(first.observation().observedAt());assertThat(bound.expiresAt()).isEqualTo(start.plusSeconds(14410));
}
@Test void endedOrChangedRootCannotBePresentedAsCurrent(){
service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=1");
assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_CHANGED");
assertThat(service.arm(new Arm("next","account",List.of("W1"),"*",null)).excludedRootTaskId()).isNull();
}
@Test void ambiguousCurrentRootsRefuseArm(){
service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='RUNNING' WHERE id=2");
assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_AMBIGUOUS");
}
@Test void diagnosticDatasourceInitializationFailureDoesNotBreakServiceConstruction(){
var unavailable=new QuantityDiagnosticService(new DriverManagerDataSource(),json,config);
assertThatThrownBy(()->unavailable.status(new Status("account","p",true,false))).hasMessageContaining("DIAGNOSTIC_DATASOURCE_UNAVAILABLE");unavailable.close();
}
@Test void fourHourExpiredDelayedFirstListIsNotAccepted(){
Event first=list("a");clock.now=start.plusSeconds(15000);var r=service.observe(first);assertThat(r.outcome()).isEqualTo("STOPPED");assertThat(r.session().candidates()).isZero();
}
@Test void rowAndByteCapsNeverRefundOrAcceptOverLimit()throws Exception{
service.observe(list("a"));jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000");
assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1);
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=1,accepted_bytes=2097152,stop_reason=NULL,active_slot=1");
assertThat(service.observe(detail("a")).outcome()).isEqualTo("STOPPED");assertThat(status().retainedBytes()).isEqualTo(2097152);
}
@Test void concurrentLastCandidateCannotExceedOneHundred()throws Exception{
for(int i=0;i<99;i++)service.observe(list("o"+i));var other=instance();var pool=Executors.newFixedThreadPool(2);
try{var a=pool.submit(()->service.observe(list("last-a")));var b=pool.submit(()->other.observe(list("last-b")));assertThat(List.of(a.get().outcome(),b.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","IGNORED");}finally{pool.shutdownNow();}assertThat(status().candidates()).isEqualTo(100);
}
@Test void admissionRejectsBeforeSecondConnectionAcquisition()throws Exception{
var tm=org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);var entered=new CountDownLatch(1);var release=new CountDownLatch(1);
org.mockito.Mockito.when(tm.getTransaction(org.mockito.ArgumentMatchers.any())).thenAnswer(call->{entered.countDown();release.await(2,TimeUnit.SECONDS);return new org.springframework.transaction.support.SimpleTransactionStatus();});
var bounded=new QuantityDiagnosticService(jdbc,tm,json,config,clock);var pool=Executors.newSingleThreadExecutor();
try{var first=pool.submit(()->bounded.status(new Status("account","producer",false,false)));assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue();assertThatThrownBy(()->bounded.status(new Status("account","producer",false,false))).hasMessageContaining("DIAGNOSTIC_BUSY");release.countDown();first.get();org.mockito.Mockito.verify(tm,org.mockito.Mockito.times(1)).getTransaction(org.mockito.ArgumentMatchers.any());}finally{release.countDown();pool.shutdownNow();}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"10","8"})
void forecastTenActualEightStopsRegardlessOfListMeaning(String listQuantity){
service.observe(event("2","a","LIST",new BigDecimal(listQuantity),null,null));assertThat(service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,new BigDecimal("8"))).comparison()).isEqualTo("QUANTITY_DIFFERENCE");
}
@Test void historicalEvidenceRemainsUntouchedAndCannotRearmItsIdentifier(){
jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_session(diagnostic_session_id,configuration_hash,source_account,collection_session_id,root_task_id,starts_at,expires_at,accepted_orders,accepted_sku_rows,accepted_bytes,stop_reason) VALUES('old',?,'account','old-session','1',?,?,24,25,1234,'EXPIRED')","a".repeat(64),start.toString(),start.toString());
assertThatThrownBy(()->service.arm(arm("old"))).hasMessageContaining("HISTORICAL_ID_READ_ONLY");assertThat(status().diagnosticSessionId()).isEqualTo("diag");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id='old'",Integer.class)).isEqualTo(24);
}
@Test void existingStopReasonAndCountersAreTheOnlyPersistedFacts(){
service.observe(list("a"));service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,BigDecimal.ONE));
assertThat(jdbc.queryForObject("SELECT stop_reason FROM ll_selfop_quantity_diagnostic_session",String.class)).isEqualTo("QUANTITY_DIFFERENCE");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session",Integer.class)).isEqualTo(1);
String metadata=jdbc.queryForObject("SELECT state_json FROM ll_selfop_quantity_diagnostic_session",String.class);assertThat(metadata).doesNotContain("\"reason\"","\"paired\"","\"bytes\"","\"expiresAt\"");assertThat(instance().status(new Status("account","producer",false,false)).stopReason()).isEqualTo("QUANTITY_DIFFERENCE");
}
static class MutableClock extends Clock {Instant now;MutableClock(Instant now){this.now=now;}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return now;}}
}