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