test(selfop): verify publication transactions against opt-in TEST MySQL

This commit is contained in:
2026-10-12 21:24:56 +08:00
parent 8526697b08
commit 06de9b1c93
@@ -0,0 +1,237 @@
package com.baiyue.lastleg.selfoperated.analytics.inventory;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.logging.nologging.NoLoggingImpl;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
import static org.junit.jupiter.api.Assertions.*;
/** Opt-in TEST-only real MySQL transactions. No collector, HTTP client, scheduler or mocked SQL. */
@EnabledIfEnvironmentVariable(named="SELFOP_TEST_MYSQL_PUBLICATION",matches="true")
@Timeout(90)
class InventoryPublicationMySqlAcceptanceTest {
private static final String ACCOUNT="SELFOP-TEST-TXN-20261012";
private static final ZoneId ZONE=ZoneId.of("Asia/Shanghai");
private static final String FAULT="SYNTH_PUBLICATION_FAULT_AFTER_RUN_WRITE";
private final String token=UUID.randomUUID().toString().replace("-","");
private final String warehouse="SELFOP-TXN-"+token.substring(0,12).toUpperCase(Locale.ROOT);
private final String owner="SYNTH-TXN-PUBLISH-"+token;
private final Map<String,Object> evidence=new LinkedHashMap<>();
private JdbcTemplate jdbc;
private TransactionTemplate transactions;
private SelfopSyncScopeMapper scopes;
private SelfopSyncTaskMapper tasks;
private SelfopSyncRunMapper runs;
private SelfopInventorySnapshotMapper stocks;
private SelfopSkuProfileMapper profiles;
private SelfopSyncScope scope;
private InventorySnapshotService service;
private LocalDate day;
private WriteFault fault;
private SelfopAnalyticsProperties properties;
@BeforeEach void initialize(TestInfo info) throws Exception {
assertEquals("test",requiredEnv("CHONGCHONG_ENV").toLowerCase(Locale.ROOT),"Only TEST is authorized");
String host=requiredEnv("CHONGCHONG_DB_HOST"),port=requiredEnv("CHONGCHONG_DB_PORT");
assertTrue(host.matches("[A-Za-z0-9.-]+")&&port.matches("[0-9]{1,5}"),"Invalid TEST connection fields");
var source=new DriverManagerDataSource("jdbc:mysql://"+host+":"+port+"/baiyue_lastleg"
+"?useSSL=false&allowPublicKeyRetrieval=true&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true"
+"&characterEncoding=UTF-8&connectTimeout=10000&socketTimeout=15000",
requiredEnv("CHONGCHONG_DB_USERNAME"),requiredEnv("CHONGCHONG_DB_PASSWORD"));
source.setDriverClassName("com.mysql.cj.jdbc.Driver");
try(var connection=source.getConnection()) {
assertEquals("MySQL",connection.getMetaData().getDatabaseProductName());
assertEquals(8,connection.getMetaData().getDatabaseMajorVersion());
assertEquals("baiyue_lastleg",connection.getCatalog());
} catch(java.sql.SQLException unavailable) {
throw new IllegalStateException("TEST MySQL connection unavailable; root must inspect connection settings");
}
jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);
var manager=new DataSourceTransactionManager(source);transactions=new TransactionTemplate(manager);
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);
configuration.setLogImpl(NoLoggingImpl.class);fault=new WriteFault();configuration.addInterceptor(fault);
for(var mapper:List.of(SelfopCoordinationMapper.class,SelfopSyncScopeMapper.class,SelfopSyncTaskMapper.class,
SelfopSyncRunMapper.class,SelfopSyncStageMapper.class,SelfopInventorySnapshotMapper.class,SelfopSkuProfileMapper.class))
configuration.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(configuration);
var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
scopes=session.getMapper(SelfopSyncScopeMapper.class);tasks=session.getMapper(SelfopSyncTaskMapper.class);
runs=session.getMapper(SelfopSyncRunMapper.class);stocks=session.getMapper(SelfopInventorySnapshotMapper.class);
profiles=session.getMapper(SelfopSkuProfileMapper.class);properties=new SelfopAnalyticsProperties();
assertEquals("OFF",properties.getCollectionMode());assertFalse(properties.isExecutionEnabled());
var target=new InventorySnapshotService(session.getMapper(SelfopCoordinationMapper.class),scopes,runs,tasks,
session.getMapper(SelfopSyncStageMapper.class),stocks,profiles,properties);
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));
service=(InventorySnapshotService)proxy.getProxy();day=LocalDate.now(ZONE);
evidence.put("case",info.getDisplayName());evidence.put("token",token);evidence.put("environment","TEST");
evidence.put("sourceAccount",ACCOUNT);evidence.put("warehouseCode",warehouse);
evidence.put("realMySqlTransactions",true);evidence.put("collectionMode","OFF");
evidence.put("sourceHttpCalls",0);evidence.put("assertionsCompleted",false);
scope=new SelfopSyncScope();scope.setId(IdWorker.getId());scope.setSourceAccount(ACCOUNT);
scope.setWarehouseCode(warehouse);scope.setDataFamily("INVENTORY");
scope.setScopeJson(json(Map.of("fixtureOrigin","OFFLINE_SYNTHETIC_TRANSACTION_ACCEPTANCE",
"quantityUnit","PCS","businessTimezone","Asia/Shanghai","sourceTimezone","Asia/Shanghai")));
scope.setScopeHash(hash(scope.getScopeJson()));scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai");
scope.setScheduleCron("0 0 8 * * *");scope.setStaleAfterSeconds(93600);
scope.setFactRevision(0L);scope.setPublishedFactRevision(0L);scope.setRebuildRequired(0);
scope.setFenceToken(1L);scope.setLeaseOwner(owner);scope.setLeaseUntilUtc(utcNow().plusMinutes(30));
scope.setReviewRequestBudget(1);scope.setReviewTimeBudgetSeconds(1);scope.setReviewTargetDays(7);
scope.setCreatedAt(utcNow());scope.setUpdatedAt(utcNow());
transactions.executeWithoutResult(s->scopes.insert(scope));evidence.put("scopeId",scope.getId().toString());
}
@Test void pointerRunTaskSupersededAndDailyElectionRollBackAfterRealSqlFault() {
var old=candidate(day.minusDays(2),day.minusDays(1),false);publish(old);
var next=candidate(day,day,false);var before=snapshot(old,next);
fault.armed=true;
RuntimeException failure=assertThrows(RuntimeException.class,()->publish(next));
boolean expected=false;for(Throwable cause=failure;cause!=null;cause=cause.getCause())
if(FAULT.equals(cause.getMessage()))expected=true;
assertTrue(expected,"Expected injected failure after actual run SQL");
assertTrue(fault.pointerWritten&&fault.realTransaction,"Actual pointer update and transaction must have occurred");
var after=snapshot(old,next);assertEquals(before,after,"Rollback must restore every publication field");
assertEquals("READY",runs.selectById(next.getId()).getStatus());assertNull(runs.selectById(next.getId()).getInventoryDailySampleDate());
assertEquals(0L,sampleCount(day));
evidence.put("before",before);evidence.put("afterRollback",after);
evidence.put("faultAfterRealScopeAndRunWrites",true);evidence.put("assertionsCompleted",true);
}
@Test void successfulPublicationAndPublishedRecoveryKeepExactCommittedResult() {
var run=candidate(day,day,false);publish(run);var before=snapshot(run);
assertPublished(run,true);publish(run);assertEquals(before,snapshot(run));
assertEquals(1L,sampleCount(day));
evidence.put("afterFirstCommit",before);evidence.put("afterRecovery",snapshot(run));
evidence.put("assertionsCompleted",true);
}
@Test void laterSameDayPublicationPreservesFirstRepresentativeAndItsRows() {
var first=candidate(day,day,false);publish(first);
var second=candidate(day,day,false);publish(second);
assertPublished(second,false);assertEquals(first.getId(),dailyRun(day));assertEquals(1L,sampleCount(day));
assertEquals(1L,rowCount("ll_selfop_inventory_snapshot","snapshot_run_id",first.getId()));
assertEquals(1L,rowCount("ll_selfop_sku_profile","profile_run_id",first.getId()));
assertNotNull(runs.selectById(first.getId()).getSupersededAtUtc());
evidence.put("afterSecondCommit",snapshot(first,second));evidence.put("assertionsCompleted",true);
}
@Test void crossMidnightPublicationHasNoDailyMarkerAndCannotBackfill() {
var first=candidate(day,day,false);publish(first);
var crossed=candidate(day.minusDays(1),day,true);publish(crossed);
assertPublished(crossed,false);assertEquals(first.getId(),dailyRun(day));
assertEquals(0L,sampleCount(day.minusDays(1)));assertEquals(1L,sampleCount(day));
evidence.put("afterCrossMidnightCommit",snapshot(first,crossed));evidence.put("assertionsCompleted",true);
}
private SelfopSyncRun candidate(LocalDate planned,LocalDate observed,boolean crossed) {
LocalDateTime start=crossed?utcDate(observed).minusMinutes(1):utcDate(observed).plusHours(1);
LocalDateTime finish=crossed?utcDate(observed).plusMinutes(1):start.plusMinutes(1);
var task=new SelfopSyncTask();task.setId(IdWorker.getId());task.setTaskNo("SYNTH-TXN-"+UUID.randomUUID().toString().toUpperCase(Locale.ROOT));
task.setExecutionPurpose("BUSINESS");task.setSourceAccount(ACCOUNT);task.setWarehouseCode(warehouse);
task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setTaskType("SCHEDULED");
task.setPlannedAtUtc(utcDate(planned).plusHours(1));task.setRequestKey("SYNTH-TXN-"+UUID.randomUUID());
task.setRequestHash(scope.getScopeHash());task.setScopeHash(scope.getScopeHash());task.setQueryScopeJson(scope.getScopeJson());
task.setStatus("RUNNING");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);
task.setCreatedAt(utcNow());task.setUpdatedAt(utcNow());
var run=new SelfopSyncRun();run.setId(IdWorker.getId());run.setTaskId(task.getId());run.setScopeId(scope.getId());
run.setExecutionPurpose("BUSINESS");run.setSourceAccount(ACCOUNT);run.setWarehouseCode(warehouse);run.setRunRole("WAREHOUSE_SYNC");
run.setAttemptNo(1);run.setScopeHash(scope.getScopeHash());run.setScopeSnapshotJson(scope.getScopeJson());run.setFenceToken(1L);
run.setBasePublishedRunId(scopes.selectById(scope.getId()).getPublishedRunId());run.setStatus("READY");run.setLastPhase("BUILD");
run.setFetchStartedAtUtc(start);run.setFetchFinishedAtUtc(finish);run.setPageCount(1);run.setSourceTotalCount(1L);
run.setUniqueObjectCount(1L);run.setCoverageStatus("COMPLETE");run.setCreatedAt(utcNow());run.setUpdatedAt(utcNow());
var stock=new SelfopInventorySnapshot();stock.setId(IdWorker.getId());stock.setSnapshotRunId(run.getId());
stock.setSourceAccount(ACCOUNT);stock.setWarehouseCode(warehouse);stock.setCustomerCode("SYNTH-TXN-OWNER");
stock.setSkuCode("SYNTH-TXN-SKU");stock.setProductName("合成事务验收商品,非领星数据");stock.setProductQuality(0);
stock.setPhysicalQuantity(BigDecimal.TEN);stock.setAvailableQuantity(BigDecimal.TEN);stock.setFrozenQuantity(BigDecimal.ZERO);
stock.setTransitQuantity(BigDecimal.ZERO);stock.setObservedAtUtc(finish);stock.setQualityStatus("VALID");
stock.setCreatedAt(utcNow());stock.setUpdatedAt(utcNow());
var profile=new SelfopSkuProfile();profile.setId(IdWorker.getId());profile.setProfileRunId(run.getId());
profile.setSourceAccount(ACCOUNT);profile.setWarehouseCode(warehouse);profile.setCustomerCode(stock.getCustomerCode());
profile.setSkuCode(stock.getSkuCode());profile.setProductName(stock.getProductName());profile.setQuantityUnit("PCS");
profile.setFirstSeenAtUtc(start);profile.setEarliestEvidenceAtUtc(start);profile.setEvidenceType("INVENTORY_OBSERVATION");
profile.setEvidenceRunId(run.getId());profile.setLastSeenRunId(run.getId());profile.setCreatedAt(utcNow());profile.setUpdatedAt(utcNow());
transactions.executeWithoutResult(s->{tasks.insert(task);runs.insert(run);stocks.insert(stock);profiles.insert(profile);});
return run;
}
private void publish(SelfopSyncRun run){service.publish(scope.getId(),run.getId(),owner,1L);}
private void assertPublished(SelfopSyncRun candidate,boolean daily){
var saved=runs.selectById(candidate.getId());var task=tasks.selectById(saved.getTaskId());
assertEquals(candidate.getId(),scopes.selectById(scope.getId()).getPublishedRunId());
assertEquals("PUBLISHED",saved.getStatus());assertNotNull(saved.getPublishedAtUtc());
assertEquals("SUCCEEDED",task.getStatus());assertEquals(saved.getId(),task.getResultPublishedRunId());
assertEquals(daily?day:null,saved.getInventoryDailySampleDate());
assertEquals(daily?"DAILY_SCHEDULED_FIRST_V1":null,saved.getInventorySampleRuleVersion());
assertEquals("OFF",properties.getCollectionMode());assertFalse(properties.isExecutionEnabled());
}
/** JdbcTemplate uses independent connections after service transaction completes. */
private Map<String,Object> snapshot(SelfopSyncRun... selected){
assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
var result=new LinkedHashMap<String,Object>();
result.put("scope",jdbc.queryForMap("SELECT CAST(published_run_id AS CHAR) published_run_id FROM ll_selfop_sync_scope WHERE id=?",scope.getId()));
for(var run:selected){
result.put("run-"+run.getId(),jdbc.queryForMap("SELECT status,published_at_utc,superseded_at_utc,inventory_daily_sample_date,inventory_sample_rule_version FROM ll_selfop_sync_run WHERE id=?",run.getId()));
result.put("task-"+run.getTaskId(),jdbc.queryForMap("SELECT status,CAST(result_published_run_id AS CHAR) result_published_run_id FROM ll_selfop_sync_task WHERE id=?",run.getTaskId()));
}return result;
}
private long sampleCount(LocalDate date){return jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run WHERE source_account=? AND warehouse_code=? AND inventory_daily_sample_date=?",Long.class,ACCOUNT,warehouse,date);}
private Long dailyRun(LocalDate date){return jdbc.queryForObject("SELECT id FROM ll_selfop_sync_run WHERE source_account=? AND warehouse_code=? AND inventory_daily_sample_date=?",Long.class,ACCOUNT,warehouse,date);}
private long rowCount(String table,String column,long id){return jdbc.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE "+column+"=?",Long.class,id);}
private static LocalDateTime utcDate(LocalDate date){return date.atStartOfDay(ZONE).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();}
private static String requiredEnv(String name){String value=System.getenv(name);if(value==null||value.isBlank())throw new IllegalStateException("Missing TEST variable "+name);return value;}
@AfterEach void preserveEvidenceAndDisableRemainingDrafts() throws Exception {
if(jdbc!=null&&scope!=null){
// Retain every row. Prevent later scheduler runs from consuming unsuccessful synthetic drafts.
transactions.executeWithoutResult(s->{
jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',next_retry_at_utc=NULL WHERE source_account=? AND scope_id=? AND status<>'SUCCEEDED'",ACCOUNT,scope.getId());
jdbc.update("UPDATE ll_selfop_sync_scope SET lease_owner=NULL,lease_until_utc=NULL WHERE id=? AND source_account=? AND lease_owner=?",scope.getId(),ACCOUNT,owner);
});
evidence.put("retainedRunHeads",jdbc.queryForList("SELECT CAST(id AS CHAR) id,status,inventory_daily_sample_date FROM ll_selfop_sync_run WHERE scope_id=? ORDER BY id",scope.getId()));
}
if(!evidence.isEmpty()){
evidence.put("at",Instant.now().toString());evidence.put("noRowsDeleted",true);
var directory=Path.of("target");Files.createDirectories(directory);
new ObjectMapper().findAndRegisterModules().writerWithDefaultPrettyPrinter().writeValue(
directory.resolve("selfop-mysql-publication-"+token+".json").toFile(),evidence);
}
}
@Intercepts(@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}))
public static class WriteFault implements Interceptor {
boolean armed,pointerWritten,realTransaction;
@Override public Object intercept(Invocation invocation) throws Throwable {
Object result=invocation.proceed(); // Every mapped SQL really executes before the fault hook.
if(armed){String id=((MappedStatement)invocation.getArgs()[0]).getId();
if(id.equals(SelfopSyncScopeMapper.class.getName()+".updateById"))pointerWritten=true;
if(id.equals(SelfopSyncRunMapper.class.getName()+".updateById")){
armed=false;realTransaction=TransactionSynchronizationManager.isActualTransactionActive();
throw new IllegalStateException(FAULT);
}
}return result;
}
}
}