fix(selfop): persist bounded next-root quantity diagnostics
This commit is contained in:
+19
-39
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+128
-154
@@ -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;}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user