Implementiere DefaultSchedulerControlUseCase für Scheduler-Orchestrierung
Implementiert SchedulerControlUseCase als zentralen Orchestrator: - start()-Sequenz mit STARTING → RUNNING_IDLE und vollständigem Rollback - stop()-Sequenz mit CAS-gesichertem STOPPING_BATCH_ACTIVE für laufende Batches - executeWrappedTick() (package-private) setzt RUNNING_BATCH_ACTIVE vor dem Trigger und leitet Folgezustand aus BatchRunTriggerResult-Variante ab - AtomicReference<SchedulerStatus> für threadsichere Zustandsverwaltung - Intervall wird beim Start aus SchedulerSettingsPort geladen, Minimum 30 s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+299
@@ -0,0 +1,299 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementiert {@link SchedulerControlUseCase} als zentraler Orchestrator
|
||||||
|
* des automatischen Schedulers.
|
||||||
|
* <p>
|
||||||
|
* Dieser Use Case:
|
||||||
|
* <ul>
|
||||||
|
* <li>Verwaltet den Scheduler-Lebenszyklus (Start, Stop) über einen
|
||||||
|
* {@link SchedulerPort} und persistiert dabei den {@code enabled}-Wert.</li>
|
||||||
|
* <li>Hält den exklusiven OS-Lock auf die Konfigurationsdatei über
|
||||||
|
* {@link ConfigurationFileLockPort}, solange der Scheduler aktiv ist.</li>
|
||||||
|
* <li>Liest beim Erstellen das konfigurierte Intervall via
|
||||||
|
* {@link SchedulerSettingsPort}.</li>
|
||||||
|
* <li>Publiziert unveränderliche Zustandssnapshots via {@link #getStatus()},
|
||||||
|
* die threadsicher über eine {@link AtomicReference} verwaltet werden.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Alle Zustandsübergänge erfolgen atomar. Der {@link BatchRunTrigger} wird
|
||||||
|
* pro Tick in einem internen Wrapper ausgeführt, der vor dem Aufruf den
|
||||||
|
* Zustand auf {@link SchedulerState#RUNNING_BATCH_ACTIVE} setzt und nach
|
||||||
|
* Abschluss den Folgezustand ableitet.
|
||||||
|
* <p>
|
||||||
|
* {@link #start()} und {@link #stop()} sind idempotent und dürfen serialisiert
|
||||||
|
* vom GUI-Worker-Thread aufgerufen werden. {@link #getStatus()} ist jederzeit
|
||||||
|
* von beliebigen Threads lesbar.
|
||||||
|
*/
|
||||||
|
public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger =
|
||||||
|
LogManager.getLogger(DefaultSchedulerControlUseCase.class);
|
||||||
|
|
||||||
|
private static final int MINIMUM_INTERVAL_SECONDS = 30;
|
||||||
|
|
||||||
|
private final SchedulerPort schedulerPort;
|
||||||
|
private final ConfigurationFileLockPort lockPort;
|
||||||
|
private final SchedulerSettingsPort settingsPort;
|
||||||
|
private final BatchRunTrigger batchRunTrigger;
|
||||||
|
private final AtomicReference<SchedulerStatus> statusRef;
|
||||||
|
private final int intervalSeconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Use Case.
|
||||||
|
* <p>
|
||||||
|
* Das Scheduler-Intervall wird sofort aus {@code settingsPort} gelesen;
|
||||||
|
* Werte unter {@value MINIMUM_INTERVAL_SECONDS} Sekunden werden auf diesen
|
||||||
|
* Mindestwert angehoben.
|
||||||
|
*
|
||||||
|
* @param schedulerPort technischer Scheduler-Mechanismus
|
||||||
|
* @param lockPort OS-Lock auf die Konfigurationsdatei
|
||||||
|
* @param settingsPort Lese-/Schreibzugriff auf Scheduler-Einstellungen
|
||||||
|
* @param batchRunTrigger Auslöser für den eigentlichen Verarbeitungslauf
|
||||||
|
*/
|
||||||
|
public DefaultSchedulerControlUseCase(
|
||||||
|
SchedulerPort schedulerPort,
|
||||||
|
ConfigurationFileLockPort lockPort,
|
||||||
|
SchedulerSettingsPort settingsPort,
|
||||||
|
BatchRunTrigger batchRunTrigger) {
|
||||||
|
this.schedulerPort = Objects.requireNonNull(schedulerPort, "schedulerPort darf nicht null sein");
|
||||||
|
this.lockPort = Objects.requireNonNull(lockPort, "lockPort darf nicht null sein");
|
||||||
|
this.settingsPort = Objects.requireNonNull(settingsPort, "settingsPort darf nicht null sein");
|
||||||
|
this.batchRunTrigger = Objects.requireNonNull(batchRunTrigger, "batchRunTrigger darf nicht null sein");
|
||||||
|
|
||||||
|
SchedulerSettings settings = settingsPort.loadSettings();
|
||||||
|
this.intervalSeconds = Math.max(settings.intervalSeconds(), MINIMUM_INTERVAL_SECONDS);
|
||||||
|
this.statusRef = new AtomicReference<>(SchedulerStatus.initial());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SchedulerControlUseCase
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den automatischen Scheduler.
|
||||||
|
* <p>
|
||||||
|
* Ist der Scheduler bereits aktiv, hat dieser Aufruf keine Wirkung (idempotent).
|
||||||
|
* Schlägt ein Startschritt fehl, wird ein vollständiger Rollback durchgeführt
|
||||||
|
* und der Zustand auf {@link SchedulerState#STOPPED} zurückgesetzt.
|
||||||
|
*
|
||||||
|
* @throws SchedulerStartException wenn der Start fehlschlägt
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void start() throws SchedulerStartException {
|
||||||
|
SchedulerStatus current = statusRef.get();
|
||||||
|
if (current.state().isActive()) {
|
||||||
|
logger.debug("Scheduler ist bereits aktiv – Start-Aufruf wird ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 1: Zustand auf STARTING setzen
|
||||||
|
statusRef.set(withState(current, SchedulerState.STARTING, Optional.empty()));
|
||||||
|
|
||||||
|
// Schritt 2: scheduler.enabled=true persistieren
|
||||||
|
try {
|
||||||
|
settingsPort.saveEnabled(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Scheduler konnte nicht gestartet werden: Einstellung nicht persistierbar.", e);
|
||||||
|
statusRef.set(SchedulerStatus.initial());
|
||||||
|
throw new SchedulerStartException(
|
||||||
|
"Scheduler-Einstellung konnte nicht gespeichert werden.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 3: OS-Lock erwerben
|
||||||
|
try {
|
||||||
|
lockPort.acquireLock();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Scheduler konnte nicht gestartet werden: Lock nicht erwerbbar.", e);
|
||||||
|
tryPersistDisabled();
|
||||||
|
statusRef.set(SchedulerStatus.initial());
|
||||||
|
throw new SchedulerStartException(
|
||||||
|
"Konfigurationsdatei konnte nicht gesperrt werden.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 4: Scheduler-Adapter starten
|
||||||
|
try {
|
||||||
|
schedulerPort.startScheduler(new SchedulerConfig(intervalSeconds), this::executeWrappedTick);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Scheduler konnte nicht gestartet werden: Adapter-Start fehlgeschlagen.", e);
|
||||||
|
lockPort.releaseLock();
|
||||||
|
tryPersistDisabled();
|
||||||
|
statusRef.set(SchedulerStatus.initial());
|
||||||
|
throw new SchedulerStartException(
|
||||||
|
"Scheduler-Adapter konnte nicht gestartet werden.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 5: Zustand auf RUNNING_IDLE setzen
|
||||||
|
Instant nextTick = Instant.now().plusSeconds(intervalSeconds);
|
||||||
|
statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_IDLE, Optional.of(nextTick)));
|
||||||
|
logger.info("Scheduler gestartet. Intervall: {} Sekunden.", intervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den automatischen Scheduler.
|
||||||
|
* <p>
|
||||||
|
* Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung
|
||||||
|
* (idempotent). Läuft gerade ein Batch-Tick, wechselt der Zustand zu
|
||||||
|
* {@link SchedulerState#STOPPING_BATCH_ACTIVE}; der laufende Batch wird
|
||||||
|
* regulär zu Ende geführt, danach wird der Zustand auf
|
||||||
|
* {@link SchedulerState#STOPPED} gesetzt.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
SchedulerStatus previous;
|
||||||
|
SchedulerStatus updated;
|
||||||
|
do {
|
||||||
|
previous = statusRef.get();
|
||||||
|
if (!previous.state().isActive()) {
|
||||||
|
logger.debug("Scheduler ist bereits gestoppt – Stop-Aufruf wird ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SchedulerState nextState = previous.state().isBatchRunning()
|
||||||
|
? SchedulerState.STOPPING_BATCH_ACTIVE
|
||||||
|
: SchedulerState.STOPPED;
|
||||||
|
updated = withState(previous, nextState, Optional.empty());
|
||||||
|
} while (!statusRef.compareAndSet(previous, updated));
|
||||||
|
|
||||||
|
boolean batchWasRunning = previous.state().isBatchRunning();
|
||||||
|
schedulerPort.stopScheduler();
|
||||||
|
|
||||||
|
if (!batchWasRunning) {
|
||||||
|
tryPersistDisabled();
|
||||||
|
lockPort.releaseLock();
|
||||||
|
logger.info("Scheduler gestoppt.");
|
||||||
|
} else {
|
||||||
|
logger.info("Stop angefordert – laufender Batch wird abgewartet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
|
||||||
|
*
|
||||||
|
* @return aktueller Scheduler-Status; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SchedulerStatus getStatus() {
|
||||||
|
return statusRef.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tick-Wrapper (package-private für Testbarkeit)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapping-Implementierung des {@link BatchRunTrigger}, die der
|
||||||
|
* {@link SchedulerPort} bei jedem Tick synchron aufruft.
|
||||||
|
* <p>
|
||||||
|
* Setzt vor dem Aufruf den Zustand auf
|
||||||
|
* {@link SchedulerState#RUNNING_BATCH_ACTIVE} und leitet nach dem
|
||||||
|
* Abschluss den Folgezustand ab:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link SchedulerState#RUNNING_IDLE} bei normalem Weiterlauf</li>
|
||||||
|
* <li>{@link SchedulerState#STOPPED} wenn ein Stopp-Befehl empfangen wurde</li>
|
||||||
|
* </ul>
|
||||||
|
* Im Fall {@link SchedulerState#STOPPED} werden außerdem {@code enabled=false}
|
||||||
|
* persistiert und der OS-Lock freigegeben.
|
||||||
|
* <p>
|
||||||
|
* Package-private, damit Unit-Tests den Tick-Wrapper direkt aufrufen können
|
||||||
|
* ohne den Scheduler-Executor zu starten.
|
||||||
|
*
|
||||||
|
* @return das Ergebnis des delegierten {@link BatchRunTrigger#triggerRun()}
|
||||||
|
*/
|
||||||
|
BatchRunTriggerResult executeWrappedTick() {
|
||||||
|
// Zustand auf RUNNING_BATCH_ACTIVE setzen
|
||||||
|
statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_BATCH_ACTIVE, Optional.empty()));
|
||||||
|
|
||||||
|
// Eigentlichen Batch ausführen
|
||||||
|
BatchRunTriggerResult result = batchRunTrigger.triggerRun();
|
||||||
|
|
||||||
|
// Folgezustand aus Ergebnis und aktuellem Zustand ableiten
|
||||||
|
SchedulerStatus afterBatch = statusRef.get();
|
||||||
|
boolean stopping = afterBatch.state() == SchedulerState.STOPPING_BATCH_ACTIVE;
|
||||||
|
SchedulerState nextState = stopping ? SchedulerState.STOPPED : SchedulerState.RUNNING_IDLE;
|
||||||
|
Optional<Instant> nextTickAt = stopping
|
||||||
|
? Optional.empty()
|
||||||
|
: Optional.of(Instant.now().plusSeconds(intervalSeconds));
|
||||||
|
|
||||||
|
Optional<Instant> lastRunEndedAt = afterBatch.lastRunEndedAt();
|
||||||
|
Optional<RunSummary> lastRunSummary = afterBatch.lastRunSummary();
|
||||||
|
Optional<String> lastError = afterBatch.lastError();
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case BatchRunTriggerResult.Started started -> {
|
||||||
|
lastRunEndedAt = Optional.of(started.endedAt());
|
||||||
|
lastRunSummary = Optional.of(started.summary());
|
||||||
|
lastError = Optional.empty();
|
||||||
|
logger.info("Scheduler-Tick abgeschlossen: {} erfolgreich, {} fehlgeschlagen, {} übersprungen.",
|
||||||
|
started.summary().successCount(),
|
||||||
|
started.summary().failedCount(),
|
||||||
|
started.summary().skippedCount());
|
||||||
|
}
|
||||||
|
case BatchRunTriggerResult.SkippedBusy ignored ->
|
||||||
|
logger.debug("Scheduler-Tick übersprungen: anderer Lauf aktiv.");
|
||||||
|
case BatchRunTriggerResult.Failed failed -> {
|
||||||
|
lastError = Optional.of(failed.userMessage());
|
||||||
|
logger.warn("Scheduler-Tick fehlgeschlagen: {}", failed.technicalMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRef.set(new SchedulerStatus(
|
||||||
|
nextState,
|
||||||
|
lastRunEndedAt,
|
||||||
|
lastRunSummary,
|
||||||
|
nextTickAt,
|
||||||
|
lastError,
|
||||||
|
afterBatch.autostartFailed()));
|
||||||
|
|
||||||
|
if (stopping) {
|
||||||
|
tryPersistDisabled();
|
||||||
|
lockPort.releaseLock();
|
||||||
|
logger.info("Scheduler gestoppt nach Abschluss des laufenden Batches.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void tryPersistDisabled() {
|
||||||
|
try {
|
||||||
|
settingsPort.saveEnabled(false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Fehler beim Zurücksetzen von scheduler.enabled auf false.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchedulerStatus withState(
|
||||||
|
SchedulerStatus base, SchedulerState state, Optional<Instant> nextTickAt) {
|
||||||
|
return new SchedulerStatus(
|
||||||
|
state,
|
||||||
|
base.lastRunEndedAt(),
|
||||||
|
base.lastRunSummary(),
|
||||||
|
nextTickAt,
|
||||||
|
base.lastError(),
|
||||||
|
base.autostartFailed());
|
||||||
|
}
|
||||||
|
}
|
||||||
+499
@@ -0,0 +1,499 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link DefaultSchedulerControlUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Teststrategien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Lifecycle-Tests (Start, Stop, Idempotenz) prüfen Zustandsübergänge
|
||||||
|
* und Interaktionen mit gemockten Ports.</li>
|
||||||
|
* <li>Tick-Tests rufen {@code executeWrappedTick()} direkt auf (package-private)
|
||||||
|
* und verifizieren die Zustandsübergänge für alle drei
|
||||||
|
* {@link BatchRunTriggerResult}-Varianten.</li>
|
||||||
|
* <li>Rollback-Tests prüfen, dass fehlgeschlagene Startschritte den Zustand
|
||||||
|
* vollständig auf {@link SchedulerState#STOPPED} zurücksetzen.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class DefaultSchedulerControlUseCaseTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SchedulerPort schedulerPort;
|
||||||
|
@Mock
|
||||||
|
private ConfigurationFileLockPort lockPort;
|
||||||
|
@Mock
|
||||||
|
private SchedulerSettingsPort settingsPort;
|
||||||
|
@Mock
|
||||||
|
private BatchRunTrigger batchRunTrigger;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Initialer Zustand
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatus_returnsInitialStatusOnCreation() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
SchedulerStatus status = useCase.getStatus();
|
||||||
|
|
||||||
|
assertThat(status.state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
assertThat(status.lastRunEndedAt()).isEmpty();
|
||||||
|
assertThat(status.lastRunSummary()).isEmpty();
|
||||||
|
assertThat(status.nextTickAt()).isEmpty();
|
||||||
|
assertThat(status.lastError()).isEmpty();
|
||||||
|
assertThat(status.autostartFailed()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_loadsSettingsIntervalSeconds() {
|
||||||
|
when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 300));
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
// Verify interval is 300 by starting and checking the SchedulerConfig passed to schedulerPort
|
||||||
|
useCase.start();
|
||||||
|
ArgumentCaptor<SchedulerConfig> configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
|
||||||
|
verify(schedulerPort).startScheduler(configCaptor.capture(), any());
|
||||||
|
assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_clampsIntervalBelowMinimumTo30() {
|
||||||
|
when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 10));
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
useCase.start();
|
||||||
|
ArgumentCaptor<SchedulerConfig> configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
|
||||||
|
verify(schedulerPort).startScheduler(configCaptor.capture(), any());
|
||||||
|
assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// start(): Normalfall
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_setsStateToRunningIdle() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_setsNextTickAt() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
Instant before = Instant.now();
|
||||||
|
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
Instant after = Instant.now();
|
||||||
|
Optional<Instant> nextTickAt = useCase.getStatus().nextTickAt();
|
||||||
|
assertThat(nextTickAt).isPresent();
|
||||||
|
assertThat(nextTickAt.get()).isAfterOrEqualTo(before.plusSeconds(179));
|
||||||
|
assertThat(nextTickAt.get()).isBeforeOrEqualTo(after.plusSeconds(181));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_persistsEnabledTrue() {
|
||||||
|
createUseCase().start();
|
||||||
|
|
||||||
|
verify(settingsPort).saveEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_acquiresLock() {
|
||||||
|
createUseCase().start();
|
||||||
|
|
||||||
|
verify(lockPort).acquireLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_startsSchedulerWithCorrectInterval() {
|
||||||
|
createUseCase().start();
|
||||||
|
|
||||||
|
ArgumentCaptor<SchedulerConfig> configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
|
||||||
|
verify(schedulerPort).startScheduler(configCaptor.capture(), any());
|
||||||
|
assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(180);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// start(): Idempotenz
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_whenAlreadyActive_isIdempotent() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
assertThatCode(useCase::start).doesNotThrowAnyException();
|
||||||
|
// Second start must NOT persist, acquire lock or start scheduler again
|
||||||
|
verify(settingsPort).saveEnabled(true); // only once
|
||||||
|
verify(lockPort).acquireLock(); // only once
|
||||||
|
verify(schedulerPort).startScheduler(any(), any()); // only once
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// start(): Rollback bei Fehlern
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_rollsBackToStoppedWhenSaveEnabledFails() {
|
||||||
|
doThrow(new SchedulerSettingsWriteException("Schreibfehler"))
|
||||||
|
.when(settingsPort).saveEnabled(true);
|
||||||
|
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
verify(lockPort, never()).acquireLock();
|
||||||
|
verify(schedulerPort, never()).startScheduler(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_rollsBackToStoppedWhenAcquireLockFails() {
|
||||||
|
doThrow(new ConfigurationFileLockException("Lock nicht verfügbar"))
|
||||||
|
.when(lockPort).acquireLock();
|
||||||
|
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
verify(settingsPort).saveEnabled(false); // rollback
|
||||||
|
verify(schedulerPort, never()).startScheduler(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_rollsBackToStoppedWhenSchedulerPortFails() {
|
||||||
|
doThrow(new RuntimeException("Executor-Startfehler"))
|
||||||
|
.when(schedulerPort).startScheduler(any(), any());
|
||||||
|
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
verify(lockPort).releaseLock();
|
||||||
|
verify(settingsPort).saveEnabled(false); // rollback
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_rollbackPersistsEnabledFalseEvenWhenRollbackFails() {
|
||||||
|
doThrow(new ConfigurationFileLockException("Timeout"))
|
||||||
|
.when(lockPort).acquireLock();
|
||||||
|
doThrow(new SchedulerSettingsWriteException("Rollback-Fehler"))
|
||||||
|
.when(settingsPort).saveEnabled(false);
|
||||||
|
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
// Even if rollback persist fails, SchedulerStartException is thrown and state is STOPPED
|
||||||
|
assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// stop(): Normalfall (kein laufender Batch)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_whenRunningIdle_stopsImmediately() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_persistsEnabledFalse() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
verify(settingsPort).saveEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_releasesLock() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
verify(lockPort).releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_stopsSchedulerAdapter() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
verify(schedulerPort).stopScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_clearsNextTickAt() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().nextTickAt()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// stop(): Idempotenz
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_whenAlreadyStopped_isIdempotent() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
|
||||||
|
assertThatCode(useCase::stop).doesNotThrowAnyException();
|
||||||
|
verify(schedulerPort, never()).stopScheduler();
|
||||||
|
verify(settingsPort, never()).saveEnabled(anyBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_calledTwice_isIdempotent() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
useCase.stop();
|
||||||
|
|
||||||
|
assertThatCode(useCase::stop).doesNotThrowAnyException();
|
||||||
|
verify(schedulerPort).stopScheduler(); // only once from the first stop
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// stop(): STOPPING_BATCH_ACTIVE-Szenario (via executeWrappedTick direkt)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stop_whenBatchRunning_setsStoppingBatchActiveAndDefersCleaup() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
// Während des triggerRun()-Aufrufs ist der Zustand RUNNING_BATCH_ACTIVE.
|
||||||
|
// Ein dazwischen gerufenes stop() setzt STOPPING_BATCH_ACTIVE.
|
||||||
|
// executeWrappedTick() erkennt das danach und setzt STOPPED.
|
||||||
|
when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
|
||||||
|
// stop() während RUNNING_BATCH_ACTIVE → setzt STOPPING_BATCH_ACTIVE
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_BATCH_ACTIVE);
|
||||||
|
useCase.stop();
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPING_BATCH_ACTIVE);
|
||||||
|
// Lock und saveEnabled(false) dürfen hier noch nicht aufgerufen worden sein
|
||||||
|
verify(lockPort, never()).releaseLock();
|
||||||
|
verify(settingsPort, never()).saveEnabled(false);
|
||||||
|
return new BatchRunTriggerResult.SkippedBusy();
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
// Erst nach Abschluss des Batches: STOPPED und Cleanup
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
verify(lockPort).releaseLock();
|
||||||
|
verify(settingsPort).saveEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// executeWrappedTick(): Zustandsübergänge für alle Ergebnisvarianten
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_started_setsRunningIdleWithLastRunData() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
Instant endedAt = Instant.now();
|
||||||
|
RunSummary summary = new RunSummary(3, 1, 2);
|
||||||
|
when(batchRunTrigger.triggerRun())
|
||||||
|
.thenReturn(new BatchRunTriggerResult.Started(endedAt, summary));
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
SchedulerStatus status = useCase.getStatus();
|
||||||
|
assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
|
||||||
|
assertThat(status.lastRunEndedAt()).contains(endedAt);
|
||||||
|
assertThat(status.lastRunSummary()).contains(summary);
|
||||||
|
assertThat(status.lastError()).isEmpty();
|
||||||
|
assertThat(status.nextTickAt()).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_started_clearsLastError() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
// Erst einen fehlgeschlagenen Tick erzeugen
|
||||||
|
when(batchRunTrigger.triggerRun())
|
||||||
|
.thenReturn(new BatchRunTriggerResult.Failed("GUI-Fehler", "technisch"));
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
assertThat(useCase.getStatus().lastError()).isPresent();
|
||||||
|
|
||||||
|
// Nun erfolgreich
|
||||||
|
when(batchRunTrigger.triggerRun())
|
||||||
|
.thenReturn(new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp()));
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().lastError()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_skippedBusy_setsRunningIdleWithoutUpdatingLastRun() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
when(batchRunTrigger.triggerRun()).thenReturn(new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
SchedulerStatus status = useCase.getStatus();
|
||||||
|
assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
|
||||||
|
assertThat(status.lastRunEndedAt()).isEmpty();
|
||||||
|
assertThat(status.lastRunSummary()).isEmpty();
|
||||||
|
assertThat(status.nextTickAt()).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_skippedBusy_preservesLastError() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
// Erst einen Fehler setzen
|
||||||
|
when(batchRunTrigger.triggerRun())
|
||||||
|
.thenReturn(new BatchRunTriggerResult.Failed("Vorheriger Fehler", "technisch"));
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
assertThat(useCase.getStatus().lastError()).isPresent();
|
||||||
|
|
||||||
|
// Dann SkippedBusy
|
||||||
|
when(batchRunTrigger.triggerRun()).thenReturn(new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().lastError()).isPresent();
|
||||||
|
assertThat(useCase.getStatus().lastError().get()).isEqualTo("Vorheriger Fehler");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_failed_setsRunningIdleWithLastError() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
when(batchRunTrigger.triggerRun())
|
||||||
|
.thenReturn(new BatchRunTriggerResult.Failed("Benutzer-Fehlermeldung", "Technische Details"));
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
SchedulerStatus status = useCase.getStatus();
|
||||||
|
assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
|
||||||
|
assertThat(status.lastError()).contains("Benutzer-Fehlermeldung");
|
||||||
|
assertThat(status.nextTickAt()).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_setsRunningBatchActiveBeforeDelegating() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
|
||||||
|
// Innerhalb des Batch-Aufrufs muss der Zustand RUNNING_BATCH_ACTIVE sein
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_BATCH_ACTIVE);
|
||||||
|
return new BatchRunTriggerResult.SkippedBusy();
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// executeWrappedTick(): STOPPING_BATCH_ACTIVE → STOPPED
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_whenStoppingBatchActive_setsStoppedAndCleansUp() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
|
||||||
|
// Simuliere: stop() wurde aufgerufen während ein Batch lief.
|
||||||
|
// stop() setzt den Zustand auf STOPPING_BATCH_ACTIVE wenn isBatchRunning().
|
||||||
|
// Da kein echter Executor läuft, testen wir den Wrapper direkt:
|
||||||
|
// Wir müssen den Zustand auf STOPPING_BATCH_ACTIVE bringen.
|
||||||
|
// Dazu nutzen wir, dass executeWrappedTick() zuerst RUNNING_BATCH_ACTIVE setzt,
|
||||||
|
// dann den Trigger aufruft. Wenn stop() dazwischen aufgerufen würde,
|
||||||
|
// würde es STOPPING_BATCH_ACTIVE setzen.
|
||||||
|
// Wir simulieren das, indem wir stop() innerhalb des triggerRun()-Aufrufs rufen:
|
||||||
|
when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
|
||||||
|
// stop() während laufendem Batch aufrufen
|
||||||
|
useCase.stop();
|
||||||
|
return new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp());
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
assertThat(useCase.getStatus().nextTickAt()).isEmpty();
|
||||||
|
verify(settingsPort).saveEnabled(false);
|
||||||
|
verify(lockPort).releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tick_whenStoppingBatchActive_setsLastRunDataBeforeStopping() {
|
||||||
|
DefaultSchedulerControlUseCase useCase = createUseCase();
|
||||||
|
useCase.start();
|
||||||
|
Instant endedAt = Instant.now();
|
||||||
|
RunSummary summary = new RunSummary(1, 0, 0);
|
||||||
|
|
||||||
|
when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
|
||||||
|
useCase.stop();
|
||||||
|
return new BatchRunTriggerResult.Started(endedAt, summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
useCase.executeWrappedTick();
|
||||||
|
|
||||||
|
assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
|
||||||
|
assertThat(useCase.getStatus().lastRunEndedAt()).contains(endedAt);
|
||||||
|
assertThat(useCase.getStatus().lastRunSummary()).contains(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsmethoden
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private DefaultSchedulerControlUseCase createUseCase() {
|
||||||
|
return new DefaultSchedulerControlUseCase(schedulerPort, lockPort, settingsPort, batchRunTrigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user