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:
+384
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user