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:
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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user