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(); 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.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")); } }