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:
@@ -95,11 +95,6 @@
|
|||||||
<goal>check</goal>
|
<goal>check</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<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>
|
<rules>
|
||||||
<rule>
|
<rule>
|
||||||
<element>BUNDLE</element>
|
<element>BUNDLE</element>
|
||||||
@@ -107,12 +102,12 @@
|
|||||||
<limit>
|
<limit>
|
||||||
<counter>LINE</counter>
|
<counter>LINE</counter>
|
||||||
<value>COVEREDRATIO</value>
|
<value>COVEREDRATIO</value>
|
||||||
<minimum>0.00</minimum>
|
<minimum>0.80</minimum>
|
||||||
</limit>
|
</limit>
|
||||||
<limit>
|
<limit>
|
||||||
<counter>BRANCH</counter>
|
<counter>BRANCH</counter>
|
||||||
<value>COVEREDRATIO</value>
|
<value>COVEREDRATIO</value>
|
||||||
<minimum>0.00</minimum>
|
<minimum>0.70</minimum>
|
||||||
</limit>
|
</limit>
|
||||||
</limits>
|
</limits>
|
||||||
</rule>
|
</rule>
|
||||||
|
|||||||
+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