Implementiere FileChannelConfigurationAccessAdapter für ConfigurationFileLockPort und SchedulerSettingsPort

Der Adapter teilt intern einen FileChannel und ermöglicht so das Schreiben
von Scheduler-Einstellungen auch während eines aktiven OS-Locks. Schreibvorgänge
laufen ohne Lock über eine temporäre Datei (ATOMIC_MOVE); mit Lock direkt über
den Kanal (Truncate → Write → Force). Zeilenenden (CRLF/LF) und alle übrigen
Properties-Zeilen bleiben unverändert erhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:14:40 +02:00
parent c2a7921675
commit aeb3323180
3 changed files with 722 additions and 7 deletions
+2 -7
View File
@@ -95,11 +95,6 @@
<goal>check</goal>
</goals>
<configuration>
<!--
Dieses Modul enthält in der aktuellen Ausbaustufe ausschließlich
eine Platzhalter-Klasse. Die Coverage-Schwellwerte werden auf 0
gesetzt und mit jedem Implementierungsschritt nachgezogen.
-->
<rules>
<rule>
<element>BUNDLE</element>
@@ -107,12 +102,12 @@
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.00</minimum>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.00</minimum>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
@@ -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}.
* <p>
* 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.
* <p>
* 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.
* <p>
* Instanzen dieser Klasse sind <em>nicht</em> 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.
* <p>
* 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}.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<String> 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<String> 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<String> 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.
* <p>
* 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;
}
}
}
@@ -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}.
* <p>
* 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;
}
}