Portiere PHP-Cronjobs auf Spring-Tasks und ergänze Legacy-Tests

Migriert die weiterhin benötigten Zeitaufgaben einschließlich SOAP-Importen, Betriebsstatus und Wiederanlaufkontrolle. Aktiviert 115 native Spring-Tasks im Docker-Image ohne Cron-Daemon oder PHP und dokumentiert die entfallenen HHA-Aufrufe. Ergänzt PHP-/UI-Testwerkzeuge und aktualisiert den Projektstand auf Version 0.2.0.
This commit is contained in:
Sven committed 2026-10-27 09:53:23 +01:00
1 parent 781a2b82dc
commit 9de2358480
57 files changed
+3251 -7

No files matched your search

@@ -0,0 +1,154 @@
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.scheduling.support.CronExpression;
import java.time.*;
import java.nio.file.*;
import java.nio.file.attribute.FileTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static de.stadtbote.votian.OrderEntryService.fields;
class LegacyCronTest {
static LegacyCronTasks tasks(LegacyCronRunner runner,MockEnvironment env){return new LegacyCronTasks(runner,mock(LegacyCronCompliance.class),mock(LegacyCronBusiness.class),mock(LegacyCronExports.class),mock(LegacyCronFiles.class),mock(LegacyCronReplication.class),mock(LegacyCronSoap.class),env);}
@Test void onlyActivePhpBranchesAndScriptsAreRegistered(){
var t=tasks(mock(LegacyCronRunner.class),new MockEnvironment());
assertThat(t.tasks).hasSize(115); // 78 mail checks, 30 exports, seven other scheduled jobs
assertThat(t.enabled).isEmpty();
assertThat(t.tasks.stream().filter(x->x.group().startsWith("hha-soap")).map(LegacyCronTasks.Task::cron)).containsExactly("0 10 5 * * *","0 20 5 * * *");
assertThat(LegacyCronTasks.unresolved()).isEmpty();
assertThat(t.tasks.stream().filter(x->x.id().endsWith("-1715"))).extracting(LegacyCronTasks.Task::id).containsExactly("stb-8-1715");
assertThat(t.tasks.stream().filter(x->x.group().equals("sb-master-export"))).hasSize(30).noneMatch(x->x.id().startsWith("sb-export-90-")||x.id().startsWith("sb-export-10-"));
assertThat(t.tasks.stream().map(LegacyCronTasks.Task::id).distinct().count()).isEqualTo(t.tasks.size());
assertThatThrownBy(()->tasks(mock(LegacyCronRunner.class),new MockEnvironment().withProperty("app.legacy-cron.jobs","typo"))).isInstanceOf(IllegalArgumentException.class);
}
@Test void containerGroupsOverrideLegacySettingAndExplicitEmptyDisablesScheduling()throws Exception{
var yaml=new org.springframework.boot.env.YamlPropertySourceLoader().load("application",new org.springframework.core.io.ClassPathResource("application.yml"));
var env=new MockEnvironment().withProperty("LEGACY_CRON_JOBS","mysql-status");
yaml.forEach(env.getPropertySources()::addLast);
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("mysql-status");
env.setProperty("SPRING_TASK_GROUPS","hha-soap-master,hha-soap-status");
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).containsExactly("hha-soap-master","hha-soap-status");
env.setProperty("SPRING_TASK_GROUPS","");
assertThat(tasks(mock(LegacyCronRunner.class),env).enabled).isEmpty();
}
@Test void allNativeGroupsAreScheduledWithoutExecutingActionsAtStartup(){
var runner=mock(LegacyCronRunner.class);var env=new MockEnvironment();
var inventory=tasks(runner,env);var groups=inventory.tasks.stream().map(LegacyCronTasks.Task::group).distinct().toList();
env.setProperty("app.legacy-cron.jobs",String.join(",",groups));
for(String group:groups)env.setProperty("app.legacy-cron.schedules."+group,"0 0 0 1 1 *");
var enabled=tasks(runner,env);
try{enabled.start();assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().getQueue()).hasSize(115);verifyNoInteractions(runner);}
finally{enabled.stop();}
assertThat(enabled.scheduler.getScheduledThreadPoolExecutor().isTerminated()).isTrue();
}
@Test void schedulesUseBerlinAndSkipWeekends(){
var tasks=tasks(mock(LegacyCronRunner.class),new MockEnvironment());var zone=LegacyCronTasks.ZONE;
var friday=ZonedDateTime.of(2026,10,23,4,0,0,0,zone);
assertThat(CronExpression.parse(tasks.tasks.stream().filter(t->t.group().equals("stb-compliance")).findFirst().orElseThrow().cron()).next(friday)).isEqualTo(ZonedDateTime.of(2026,10,26,4,0,0,0,zone));
assertThat(CronExpression.parse("0 45 20 * * *").next(friday)).isEqualTo(friday.withHour(20).withMinute(45));
var transition=ZonedDateTime.of(2026,3,28,20,45,0,0,zone);
assertThat(CronExpression.parse("0 45 20 * * *").next(transition).getOffset()).isEqualTo(ZoneOffset.ofHours(2));
}
@Test void delayedExecutionRetainsTheScheduledSlot(){
var runner=mock(LegacyCronRunner.class);var tasks=tasks(runner,new MockEnvironment());var calls=new ArrayList<Instant>();
var task=new LegacyCronTasks.Task("x","x","x","0 0 4 * * *","x",slot->{calls.add(slot);return LegacyCronRunner.Result.ok(1);});
doAnswer(a->{java.util.function.Supplier<?> action=a.getArgument(2);return action.get();}).when(runner).execute(anyString(),any(),any());
var slot=Instant.parse("2026-10-26T03:00:00Z");tasks.execute(task,slot);assertThat(calls).containsExactly(slot);
}
@Test void monitoringBatchesReceiptsAndUsesBrowserCompatibleTimestamps(){
var db=mock(DatabaseService.class);var runner=new LegacyCronRunner(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()));
var service=tasks(runner,new MockEnvironment().withProperty("app.legacy-cron.jobs","mysql-status"));
var active=service.view().stream().filter(r->r.get("id").equals("mysql-status")).findFirst().orElseThrow();
assertThat(active.get("state")).isEqualTo("WAITING");assertThatCode(()->Instant.parse(active.get("nextRun").toString())).doesNotThrowAnyException();
verify(db,times(1)).queryForList(anyString());
}
static class MemoryRunner extends LegacyCronRunner {
Run saved;
MemoryRunner(DatabaseService db){super(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()));}
@Override Run find(String id){return saved;}
@Override void save(Run r,boolean insert){saved=r;}
}
@Test void persistentClaimPreventsReplayAndBlocksLaterRunsAfterUncertainSmtp(){
var db=mock(DatabaseService.class);doAnswer(a->{java.util.function.Supplier<?> f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any());
var r=new MemoryRunner(db);var count=new AtomicInteger();var slot=Instant.parse("2026-10-26T03:00:00Z");
r.execute("mail",slot,()->{count.incrementAndGet();return new LegacyCronRunner.Result("UNCERTAIN","SMTP",1);});
var restarted=new MemoryRunner(db);restarted.saved=r.saved;
restarted.execute("mail",slot,()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});
restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});
assertThat(count).hasValue(1);assertThat(restarted.saved.result().state()).isEqualTo("UNCERTAIN");
restarted.saved=new LegacyCronRunner.Run("mail",slot.toString(),"","",new LegacyCronRunner.Result("REVIEWED","",0));
restarted.execute("mail",slot.plusSeconds(86400),()->{count.incrementAndGet();return LegacyCronRunner.Result.ok(1);});assertThat(count).hasValue(2);
}
@Test void lockBusyAndReceiptWriteFailureNeverInvokeBusinessAction(){
var db=mock(DatabaseService.class);var action=mock(java.util.function.Supplier.class);var r=new MemoryRunner(db);
r.execute("mail",Instant.now(),action);verifyNoInteractions(action);
doAnswer(a->{java.util.function.Supplier<?> f=a.getArgument(1);return f.get();}).when(db).cronOperation(anyString(),any());
var broken=new MemoryRunner(db){@Override void save(Run r,boolean insert){throw new IllegalStateException("DB down");}};
assertThatThrownBy(()->broken.execute("mail",Instant.now(),action)).isInstanceOf(IllegalStateException.class);verifyNoInteractions(action);
}
@Test void compliancePreservesOtherFiltersAndPhpMonthOverflow(){
assertThat(LegacyCronCompliance.filter(",VA,PBSG,FZ,","PBSG",false)).isEqualTo(",VA,FZ,");
assertThat(LegacyCronCompliance.filter(",VA,PBSG,","PBSG",true)).isEqualTo(",VA,PBSG,");
assertThat(LegacyCronCompliance.expiry("31.08.2026",6)).isEqualTo(LocalDate.of(2027,3,3));
assertThatThrownBy(()->LegacyCronCompliance.expiry("31.02.2026",0)).hasMessageContaining("Ungültiges Datum");
var date=LocalDate.of(2026,10,26);
assertThat(LegacyCronCompliance.color(date.minusDays(1),date,false)).isEqualTo("FF0000");
assertThat(LegacyCronCompliance.color(date,date,false)).isEqualTo("FF6666");
assertThat(LegacyCronCompliance.color(date.plusDays(181),date,true)).isEmpty();
assertThat(LegacyCronCompliance.RULES.stream().filter(r->r.script()==1629).findFirst().orElseThrow().field()).isEqualTo(441);
}
@Test void mysqlRatesDoNotInventDeltasAfterAReset(){
var current=Map.of("Uptime","600","Questions","1200","Slow_queries","3","Qcache_hits","20","Com_select","80");
var first=LegacyCronBusiness.rates(current,Map.of());assertThat(first.get("qps")).isEqualTo("2.0");assertThat(first.get("qps_now")).isEmpty();assertThat(first.get("Ratio")).isEqualTo("20.0");
assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","700","Questions","2000")).get("qps_now")).isEmpty();
assertThat(LegacyCronBusiness.rates(current,Map.of("Uptime","300","Questions","300")).get("qps_now")).isEqualTo("3.0");
}
@Test void exportFailureAfterStartingStorageRequiresReviewBeforeTheNextRun(){
var db=mock(DatabaseService.class);var exports=mock(MasterExports.class);var runs=mock(MasterExportRuns.class);var transfer=mock(MasterExportTransfer.class);
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(a->a.<String>getArgument(0).contains("FROM user")?List.of(fields("usr_id",100001,"emp_id",1,"usr_name","Cron","emp_rights","1111111")):List.of(fields("expp_id",12)));
var state=new MasterExports.State(1,List.of(),List.of(),Map.of());when(exports.state(any(),eq(1),eq(12L))).thenReturn(state);
var selection=new MasterExportData.Selection(List.of(),List.of(Map.of("cmp_id",1)),List.of(),0);
when(exports.snapshot(any(),any())).thenReturn(new MasterExports.Snapshot(state,selection,"payload","revision"));
when(runs.start(any(LegacyUser.class),any())).thenThrow(AdminSupport.bad("partial storage failure"));
var result=new LegacyCronExports(db,exports,runs,transfer).run(LegacyCronExports.BRANCHES.getFirst(),1,Instant.parse("2026-10-26T19:45:00Z"));
assertThat(result.state()).isEqualTo("UNCERTAIN");verifyNoInteractions(transfer);
}
@TempDir Path root;
@Test void fileMaintenanceCompressesOnlyInactiveFilesAndDoesNotFollowLinks()throws Exception{
var bin=Files.createDirectory(root.resolve("bin"));var logs=Files.createDirectory(root.resolve("logs"));var temp=Files.createDirectory(root.resolve("temp"));
var old=Files.writeString(bin.resolve("mps2-bin.000001"),"old");var active=Files.writeString(bin.resolve("mps2-bin.000002"),"active");
Files.setLastModifiedTime(old,FileTime.fromMillis(1000));Files.setLastModifiedTime(active,FileTime.fromMillis(2000));
Files.writeString(logs.resolve("app_202609.log"),"past month");Files.writeString(logs.resolve("app_202610.log"),"current");
var outside=Files.writeString(root.resolve("preserve"),"keep");Files.createSymbolicLink(temp.resolve("link"),outside);Files.writeString(temp.resolve("download"),"delete");
var env=new MockEnvironment().withProperty("app.legacy-cron.files.binlog-directories",bin.toRealPath().toString()).withProperty("app.legacy-cron.files.log-directories",logs.toRealPath().toString()).withProperty("app.legacy-cron.files.temporary-directories",temp.toRealPath().toString());
var result=new LegacyCronFiles(env).run(LocalDate.of(2026,10,26));assertThat(result.processed()).isEqualTo(3);
assertThat(old).doesNotExist();assertThat(bin.resolve("mps2-bin.000001.bz2")).exists();assertThat(active).hasContent("active");
assertThat(logs.resolve("app_202609.log.bz2")).exists();assertThat(logs.resolve("app_202610.log")).hasContent("current");assertThat(outside).hasContent("keep");assertThat(Files.isSymbolicLink(temp.resolve("link"))).isTrue();
assertThat(new LegacyCronFiles(env).run(LocalDate.of(2026,10,26)).processed()).isZero();
}
@Test void compressionRefusesConflictingArchiveAndOverlappingCleanupRoots()throws Exception{
var file=Files.writeString(root.resolve("history.log"),"original");Files.writeString(root.resolve("history.log.bz2"),"invalid archive");
assertThatThrownBy(()->LegacyCronFiles.compress(file)).isInstanceOf(java.io.IOException.class);assertThat(file).hasContent("original");
var dir=root.toRealPath().toString();var env=new MockEnvironment().withProperty("app.legacy-cron.files.log-directories",dir).withProperty("app.legacy-cron.files.temporary-directories",dir);
assertThatThrownBy(()->new LegacyCronFiles(env).run(LocalDate.now())).hasMessageContaining("überlappen");assertThat(file).exists();
}
@Test void replicaCountersKeepPhpThresholdsAndAlertOnUnavailableReplica(){
var db=mock(DatabaseService.class);var mail=mock(AppointmentMailTransport.class);when(mail.configured()).thenReturn(true);
var env=new MockEnvironment().withProperty("app.legacy-cron.replication.targets","{\"STB\":[\"primary:3306\",\"replica:3306\"]}").withProperty("app.legacy-cron.replication.user","qa").withProperty("app.legacy-cron.replication.mail-to","qa@example.invalid");
var service=new LegacyCronReplication(db,new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper()),mail,env);
when(db.cronReplicaCount(eq("primary:3306"),any(),any(),any(),any())).thenReturn(10L);when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenReturn(6L);
assertThat(service.run(LocalDate.of(2026,10,26)).state()).isEqualTo("OK");verify(mail,never()).sendOrderHtml(any(),any(),any(),any(),any(),any());
assertThat(LegacyCronReplication.GROUPS.get("INT").tolerance()).isEqualTo(7);
org.springframework.test.util.ReflectionTestUtils.setField(mail,"from","sender@example.invalid");
when(db.cronReplicaCount(eq("replica:3306"),any(),any(),any(),any())).thenThrow(new IllegalStateException("password=not-for-output"));
var failed=service.run(LocalDate.of(2026,10,26));assertThat(failed.state()).isEqualTo("ERROR");assertThat(failed.message()).contains("nicht erreichbar").doesNotContain("password");
verify(mail).sendOrderHtml(eq("sender@example.invalid"),eq(List.of("qa@example.invalid")),eq(List.of()),eq(List.of()),anyString(),contains("nicht erreichbar"));
}
}