diff --git a/pdf-umbenenner-adapter-in-scheduler/pom.xml b/pdf-umbenenner-adapter-in-scheduler/pom.xml index 90dbcf4..01a1fae 100644 --- a/pdf-umbenenner-adapter-in-scheduler/pom.xml +++ b/pdf-umbenenner-adapter-in-scheduler/pom.xml @@ -95,11 +95,6 @@ check - BUNDLE @@ -107,12 +102,12 @@ LINE COVEREDRATIO - 0.00 + 0.80 BRANCH COVEREDRATIO - 0.00 + 0.70 diff --git a/pdf-umbenenner-adapter-in-scheduler/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapter.java b/pdf-umbenenner-adapter-in-scheduler/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapter.java new file mode 100644 index 0000000..b787991 --- /dev/null +++ b/pdf-umbenenner-adapter-in-scheduler/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapter.java @@ -0,0 +1,384 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.scheduler; + +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.SchedulerSettings; +import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort; +import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import java.util.regex.Pattern; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Implementiert {@link ConfigurationFileLockPort} und {@link SchedulerSettingsPort} + * auf Basis eines gemeinsam genutzten {@link FileChannel}. + *

+ * Der exklusive OS-Lock auf die {@code .properties}-Datei wird über + * {@link FileChannel#tryLock()} mit einer Deadline-Wiederholschleife erworben. + * Solange der Lock gehalten wird, erfolgen Schreibvorgänge direkt über + * den bereits offenen Kanal (Truncate → Position(0) → Write → Force). + * Ohne aktiven Lock werden Schreibvorgänge über eine temporäre Datei + * und {@link Files#move} mit {@code ATOMIC_MOVE} und {@code REPLACE_EXISTING} + * durchgeführt. + *

+ * Beide Ports teilen den internen {@link FileChannel}, damit + * Settings-Schreibvorgänge auch während eines aktiven OS-Locks korrekt + * in die Konfigurationsdatei durchgeschrieben werden können. + *

+ * Instanzen dieser Klasse sind nicht Thread-sicher. Der Aufrufer + * ist für die Serialisierung konkurrierender Zugriffe verantwortlich. + */ +public class FileChannelConfigurationAccessAdapter + implements ConfigurationFileLockPort, SchedulerSettingsPort { + + private static final Logger logger = + LogManager.getLogger(FileChannelConfigurationAccessAdapter.class); + + private static final long ACQUIRE_TIMEOUT_MS = 3000L; + private static final long ACQUIRE_RETRY_INTERVAL_MS = 100L; + + private static final String KEY_ENABLED = "scheduler.enabled"; + private static final String KEY_INTERVAL = "scheduler.interval.seconds"; + + private final Path configFile; + + private FileChannel channel; + private FileLock fileLock; + + /** + * Erstellt einen neuen Adapter für die angegebene Konfigurationsdatei. + * + * @param configFile Pfad zur {@code .properties}-Konfigurationsdatei; + * darf nicht {@code null} sein + */ + public FileChannelConfigurationAccessAdapter(Path configFile) { + this.configFile = Objects.requireNonNull(configFile, "configFile darf nicht null sein"); + } + + // ------------------------------------------------------------------------- + // ConfigurationFileLockPort + // ------------------------------------------------------------------------- + + /** + * Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei. + *

+ * Ist der Lock bereits durch diese Instanz gehalten, hat dieser Aufruf + * keine Wirkung (idempotent). Andernfalls wird der {@link FileChannel} + * mit {@link StandardOpenOption#READ} und {@link StandardOpenOption#WRITE} + * geöffnet und {@link FileChannel#tryLock()} in einer Schleife mit + * {@value ACQUIRE_RETRY_INTERVAL_MS}-ms-Pausen versucht. Schlägt der + * Erwerb innerhalb von {@value ACQUIRE_TIMEOUT_MS} ms fehl, werden + * Kanal und Lock geschlossen und eine {@link ConfigurationFileLockException} + * geworfen. + * + * @throws ConfigurationFileLockException wenn der Lock nicht innerhalb der + * Deadline erworben werden kann, ein I/O-Fehler auftritt oder der + * Thread unterbrochen wird + */ + @Override + public void acquireLock() throws ConfigurationFileLockException { + if (isLocked()) { + return; + } + long deadline = System.currentTimeMillis() + ACQUIRE_TIMEOUT_MS; + try { + channel = FileChannel.open(configFile, + StandardOpenOption.READ, StandardOpenOption.WRITE); + while (true) { + try { + FileLock lock = channel.tryLock(); + if (lock != null) { + this.fileLock = lock; + logger.debug("OS-Lock auf Konfigurationsdatei erworben: {}", configFile); + return; + } + } catch (OverlappingFileLockException e) { + // Dieselbe JVM hält bereits einen Lock auf diesen Dateibereich; + // wird wie ein nicht verfügbarer Lock behandelt. + } + if (System.currentTimeMillis() >= deadline) { + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Konfigurationsdatei konnte nicht gesperrt werden: " + + "Timeout nach " + ACQUIRE_TIMEOUT_MS + " ms. Datei: " + configFile); + } + Thread.sleep(ACQUIRE_RETRY_INTERVAL_MS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Lock-Erwerb auf Konfigurationsdatei wurde unterbrochen.", e); + } catch (IOException e) { + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Konfigurationsdatei konnte nicht geöffnet oder gesperrt werden: " + + configFile, e); + } + } + + /** + * Gibt den exklusiven Lock frei und schließt den {@link FileChannel}. + *

+ * Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent). + * Aufgetretene I/O-Fehler werden geloggt und still übergangen. + */ + @Override + public void releaseLock() { + if (fileLock != null) { + try { + fileLock.release(); + logger.debug("OS-Lock auf Konfigurationsdatei freigegeben: {}", configFile); + } catch (IOException e) { + logger.warn("Fehler beim Freigeben des FileLock für {}.", configFile, e); + } + fileLock = null; + } + closeChannelSilently(); + } + + /** + * Prüft, ob der Lock aktuell von dieser Instanz gehalten wird. + * + * @return {@code true}, wenn der Lock aktiv und gültig ist + */ + @Override + public boolean isLocked() { + return fileLock != null && fileLock.isValid(); + } + + // ------------------------------------------------------------------------- + // SchedulerSettingsPort + // ------------------------------------------------------------------------- + + /** + * Liest die aktuellen Scheduler-Einstellungen aus der Konfigurationsdatei. + *

+ * Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert aus + * {@link SchedulerSettings#defaults()} zurückgegeben. Ungültige Werte + * (z.B. nicht-numerisches Intervall) führen ebenfalls zu den Standardwerten, + * nicht zu einer Exception. + * + * @return aktuelle Scheduler-Einstellungen; nie {@code null} + */ + @Override + public SchedulerSettings loadSettings() { + Properties props = new Properties(); + try { + String content = Files.readString(configFile, StandardCharsets.UTF_8); + props.load(new StringReader(content)); + } catch (IOException e) { + logger.warn("Scheduler-Einstellungen konnten nicht geladen werden, " + + "Standardwerte werden verwendet. Datei: {}", configFile, e); + return SchedulerSettings.defaults(); + } + boolean enabled = parseEnabled(props.getProperty(KEY_ENABLED)); + int intervalSeconds = parseInterval(props.getProperty(KEY_INTERVAL)); + return new SchedulerSettings(enabled, intervalSeconds); + } + + /** + * Schreibt den Wert von {@code scheduler.enabled} in die Konfigurationsdatei. + *

+ * Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key + * noch nicht, wird er am Ende der Datei ergänzt. + * + * @param enabled neuer Wert für {@code scheduler.enabled} + * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt + */ + @Override + public void saveEnabled(boolean enabled) throws SchedulerSettingsWriteException { + updateProperty(KEY_ENABLED, String.valueOf(enabled)); + } + + /** + * Schreibt den Wert von {@code scheduler.interval.seconds} in die + * Konfigurationsdatei. + *

+ * Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key + * noch nicht, wird er am Ende der Datei ergänzt. + * + * @param seconds neues Intervall in Sekunden + * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt + */ + @Override + public void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException { + updateProperty(KEY_INTERVAL, String.valueOf(seconds)); + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden: Parsen + // ------------------------------------------------------------------------- + + private boolean parseEnabled(String raw) { + if (raw == null || raw.isBlank()) { + return SchedulerSettings.DEFAULT_ENABLED; + } + return Boolean.parseBoolean(raw.trim()); + } + + private int parseInterval(String raw) { + if (raw == null || raw.isBlank()) { + return SchedulerSettings.DEFAULT_INTERVAL_SECONDS; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + return SchedulerSettings.DEFAULT_INTERVAL_SECONDS; + } + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden: format-erhaltende Schreiblogik + // ------------------------------------------------------------------------- + + private void updateProperty(String key, String value) throws SchedulerSettingsWriteException { + try { + byte[] rawBytes = isLocked() ? readAllBytesViaChannel() : Files.readAllBytes(configFile); + String separator = detectLineSeparator(rawBytes); + String rawContent = new String(rawBytes, StandardCharsets.UTF_8); + List lines = splitLines(rawContent, separator); + updateOrAppend(lines, key, value); + String newContent = String.join(separator, lines); + writeContent(newContent); + } catch (IOException e) { + throw new SchedulerSettingsWriteException( + "Einstellung '" + key + "' konnte nicht in " + + configFile + " geschrieben werden.", e); + } + } + + /** + * Liest den vollständigen Dateiinhalt über den gemeinsamen {@link FileChannel}. + * Wird verwendet, wenn ein OS-Lock aktiv ist und {@link Files#readAllBytes} auf + * Windows die gesperrte Datei nicht öffnen kann. + */ + private byte[] readAllBytesViaChannel() throws IOException { + long fileSize = channel.size(); + channel.position(0); + ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.max(fileSize, 0)); + ByteBuffer buf = ByteBuffer.allocate(8192); + while (channel.read(buf) != -1) { + buf.flip(); + out.write(buf.array(), 0, buf.limit()); + buf.clear(); + } + return out.toByteArray(); + } + + /** + * Erkennt das Zeilentrennzeichen anhand der ersten vorkommenden Byte-Sequenz. + * Findet die Methode {@code \r\n}, wird {@code "\r\n"} zurückgegeben; + * andernfalls {@code "\n"}. + */ + private String detectLineSeparator(byte[] rawContent) { + for (int i = 0; i < rawContent.length - 1; i++) { + if (rawContent[i] == '\r' && rawContent[i + 1] == '\n') { + return "\r\n"; + } + } + return "\n"; + } + + private List splitLines(String content, String separator) { + String[] parts = content.split(Pattern.quote(separator), -1); + return new ArrayList<>(Arrays.asList(parts)); + } + + /** + * Sucht die erste Zeile, die den angegebenen Key definiert, und ersetzt den + * Wert. Wird keine passende Zeile gefunden, wird der Key am Ende der Datei + * eingefügt – unmittelbar vor einer abschließenden Leerzeile, sofern vorhanden. + */ + private void updateOrAppend(List lines, String key, String value) { + for (int i = 0; i < lines.size(); i++) { + if (isKeyLine(lines.get(i), key)) { + lines.set(i, key + "=" + value); + return; + } + } + // Key nicht gefunden: vor abschließender Leerzeile einfügen, sonst anhängen. + if (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) { + lines.add(lines.size() - 1, key + "=" + value); + } else { + lines.add(key + "=" + value); + } + } + + /** + * Prüft, ob die Zeile eine Property-Definition für genau den angegebenen Key + * darstellt. Kommentarzeilen (beginnend mit {@code #} oder {@code !}) werden + * immer als nicht-passend bewertet. + */ + private boolean isKeyLine(String line, String key) { + String trimmed = line.stripLeading(); + if (trimmed.startsWith("#") || trimmed.startsWith("!")) { + return false; + } + if (!trimmed.startsWith(key)) { + return false; + } + int afterKey = key.length(); + if (afterKey >= trimmed.length()) { + return false; // Zeile enthält nur den Schlüssel ohne Trennzeichen + } + char next = trimmed.charAt(afterKey); + return next == '=' || next == ':' || Character.isWhitespace(next); + } + + /** + * Schreibt den Inhalt in die Konfigurationsdatei. + *

+ * Ist der OS-Lock aktiv, wird über den gemeinsamen {@link FileChannel} + * geschrieben (Truncate → Position(0) → Write → Force). Ist kein Lock aktiv, + * wird eine temporäre Datei erzeugt und danach atomar verschoben. + */ + private void writeContent(String content) throws IOException { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + if (isLocked()) { + channel.truncate(0); + channel.position(0); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } else { + Path tempFile = configFile.resolveSibling(configFile.getFileName() + ".tmp"); + Files.writeString(tempFile, content, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + Files.move(tempFile, configFile, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void closeChannelSilently() { + if (channel != null) { + try { + channel.close(); + } catch (IOException e) { + logger.warn("Fehler beim Schließen des FileChannel für {}.", configFile, e); + } + channel = null; + } + } +} diff --git a/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java b/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java new file mode 100644 index 0000000..3965c26 --- /dev/null +++ b/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java @@ -0,0 +1,336 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.scheduler; + +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 java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException; +import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings; +import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException; + +/** + * Unit-Tests für {@link FileChannelConfigurationAccessAdapter}. + *

+ * Deckt das Lock-Protokoll (Erwerb, Freigabe, Idempotenz), die Settings-Lese- + * und Schreiblogik sowie die format-erhaltenden Zeileneigenschaften ab. + * Alle Tests arbeiten auf einer temporären {@code .properties}-Datei im + * JUnit-eigenen {@code @TempDir}. + */ +class FileChannelConfigurationAccessAdapterTest { + + // ========================================================================= + // Lock-Protokoll + // ========================================================================= + + @Test + void isLocked_returnsFalseBeforeAnyAcquire(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + assertThat(adapter.isLocked()).isFalse(); + } + + @Test + void acquireLock_setsIsLockedTrue(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.acquireLock(); + try { + assertThat(adapter.isLocked()).isTrue(); + } finally { + adapter.releaseLock(); + } + } + + @Test + void releaseLock_setsIsLockedFalse(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.acquireLock(); + adapter.releaseLock(); + + assertThat(adapter.isLocked()).isFalse(); + } + + @Test + void acquireLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.acquireLock(); + try { + assertThatCode(adapter::acquireLock).doesNotThrowAnyException(); + assertThat(adapter.isLocked()).isTrue(); + } finally { + adapter.releaseLock(); + } + } + + @Test + void releaseLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.acquireLock(); + adapter.releaseLock(); + + assertThatCode(adapter::releaseLock).doesNotThrowAnyException(); + assertThat(adapter.isLocked()).isFalse(); + } + + @Test + void releaseLock_withoutPriorAcquire_doesNotThrow(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + assertThatCode(adapter::releaseLock).doesNotThrowAnyException(); + } + + @Test + void acquireLock_throwsConfigurationFileLockException_whenFileDoesNotExist( + @TempDir Path tempDir) { + Path nonExistent = tempDir.resolve("missing.properties"); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(nonExistent); + + assertThatThrownBy(adapter::acquireLock) + .isInstanceOf(ConfigurationFileLockException.class); + } + + // ========================================================================= + // loadSettings + // ========================================================================= + + @Test + void loadSettings_returnsDefaultsWhenKeysAreMissing(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, "source.folder=S:\\source\n"); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + SchedulerSettings settings = adapter.loadSettings(); + + assertThat(settings.enabled()).isEqualTo(SchedulerSettings.DEFAULT_ENABLED); + assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS); + } + + @Test + void loadSettings_returnsConfiguredValues(@TempDir Path tempDir) throws IOException { + String content = "scheduler.enabled=true\nscheduler.interval.seconds=300\n"; + Path config = createConfigFile(tempDir, content); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + SchedulerSettings settings = adapter.loadSettings(); + + assertThat(settings.enabled()).isTrue(); + assertThat(settings.intervalSeconds()).isEqualTo(300); + } + + @Test + void loadSettings_returnsDefaultIntervalForNonNumericValue(@TempDir Path tempDir) + throws IOException { + String content = "scheduler.enabled=false\nscheduler.interval.seconds=not-a-number\n"; + Path config = createConfigFile(tempDir, content); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + SchedulerSettings settings = adapter.loadSettings(); + + assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS); + } + + @Test + void loadSettings_returnsDefaultEnabledForBlankValue(@TempDir Path tempDir) + throws IOException { + String content = "scheduler.enabled=\nscheduler.interval.seconds=180\n"; + Path config = createConfigFile(tempDir, content); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + SchedulerSettings settings = adapter.loadSettings(); + + assertThat(settings.enabled()).isEqualTo(SchedulerSettings.DEFAULT_ENABLED); + } + + @Test + void loadSettings_returnsDefaultsWhenFileIsEmpty(@TempDir Path tempDir) throws IOException { + Path config = createConfigFile(tempDir, ""); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + SchedulerSettings settings = adapter.loadSettings(); + + assertThat(settings).isEqualTo(SchedulerSettings.defaults()); + } + + // ========================================================================= + // saveEnabled + // ========================================================================= + + @Test + void saveEnabled_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir) + throws IOException { + String initial = "source.folder=/opt/source\nscheduler.enabled=false\ntarget.folder=/opt/target\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveEnabled(true); + + Properties props = loadProperties(config); + assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true"); + assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source"); + assertThat(props.getProperty("target.folder")).isEqualTo("/opt/target"); + } + + @Test + void saveEnabled_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException { + String initial = "source.folder=/opt/source\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveEnabled(true); + + Properties props = loadProperties(config); + assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true"); + assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source"); + } + + @Test + void saveEnabled_writesCorrectlyThroughChannelWhenLocked(@TempDir Path tempDir) + throws IOException { + String initial = "scheduler.enabled=false\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.acquireLock(); + try { + adapter.saveEnabled(true); + } finally { + adapter.releaseLock(); + } + + Properties props = loadProperties(config); + assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true"); + } + + @Test + void saveEnabled_throwsSchedulerSettingsWriteException_whenFileDoesNotExist( + @TempDir Path tempDir) { + Path nonExistent = tempDir.resolve("missing.properties"); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(nonExistent); + + assertThatThrownBy(() -> adapter.saveEnabled(true)) + .isInstanceOf(SchedulerSettingsWriteException.class); + } + + // ========================================================================= + // saveIntervalSeconds + // ========================================================================= + + @Test + void saveIntervalSeconds_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir) + throws IOException { + String initial = "scheduler.interval.seconds=180\nscheduler.enabled=false\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveIntervalSeconds(300); + + Properties props = loadProperties(config); + assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300"); + assertThat(props.getProperty("scheduler.enabled")).isEqualTo("false"); + } + + @Test + void saveIntervalSeconds_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException { + String initial = "scheduler.enabled=true\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveIntervalSeconds(240); + + Properties props = loadProperties(config); + assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("240"); + assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true"); + } + + // ========================================================================= + // Zeilenenden-Erhaltung + // ========================================================================= + + @Test + void saveEnabled_preservesCrlfLineEndings(@TempDir Path tempDir) throws IOException { + String initial = "scheduler.enabled=false\r\nother.key=value\r\n"; + Path config = createConfigFileBinary(tempDir, initial.getBytes(StandardCharsets.UTF_8)); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveEnabled(true); + + byte[] resultBytes = Files.readAllBytes(config); + String result = new String(resultBytes, StandardCharsets.UTF_8); + assertThat(result).contains("scheduler.enabled=true\r\n"); + assertThat(result).contains("other.key=value\r\n"); + } + + @Test + void saveEnabled_preservesLfLineEndings(@TempDir Path tempDir) throws IOException { + String initial = "scheduler.enabled=false\nother.key=value\n"; + Path config = createConfigFile(tempDir, initial); + FileChannelConfigurationAccessAdapter adapter = + new FileChannelConfigurationAccessAdapter(config); + + adapter.saveEnabled(true); + + String result = Files.readString(config, StandardCharsets.UTF_8); + assertThat(result).contains("scheduler.enabled=true\n"); + assertThat(result).contains("other.key=value\n"); + assertThat(result).doesNotContain("\r\n"); + } + + // ========================================================================= + // Hilfsmethoden + // ========================================================================= + + private static Path createConfigFile(Path tempDir, String content) throws IOException { + Path config = tempDir.resolve("test.properties"); + Files.writeString(config, content, StandardCharsets.UTF_8); + return config; + } + + private static Path createConfigFileBinary(Path tempDir, byte[] bytes) throws IOException { + Path config = tempDir.resolve("test.properties"); + Files.write(config, bytes); + return config; + } + + private static Properties loadProperties(Path file) throws IOException { + Properties props = new Properties(); + props.load(new StringReader(Files.readString(file, StandardCharsets.UTF_8))); + return props; + } +}