feat(selfop): add bounded independent quantity diagnostic capture

This commit is contained in:
2026-10-13 23:40:44 +08:00
parent 54f79b3902
commit 3d3507a770
18 changed files with 1190 additions and 1 deletions
@@ -0,0 +1,47 @@
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.*;
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);
}
}
@@ -0,0 +1,169 @@
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;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
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;
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);
}
}