M12 vollständig abgeschlossen (AP-001 bis AP-008)

- AP-001: Prüf- und Korrektur-Kernobjekte (CheckpointId, CheckpointResult
  sealed interface, TechnicalTestReport mit Correction-Plan-Ableitung,
  CorrectionSuggestion sealed interface, PathCheckPort, ResourceCreationPort)
- AP-002: Aktion "Validieren" als explizite, nicht schreibende Gesamtprüfung
  des aktuellen Editorzustands
- AP-003: Provider-nahe technische Prüflogik für Endpoint, API-Key,
  Modellliste und Modellplausibilität — wiederverwendet den bestehenden
  Modellabruf-Port, kein zweiter HTTP-Pfad
- AP-004: Windows-Pfadprüfung mit ausdrücklicher Unterstützung gemappter
  Laufwerksbuchstaben (FilesystemPathCheckAdapter)
- AP-005: Aktion "Technische Tests ausführen" als vollständiger Gesamttest
  ohne Frühabbruch, Orchestrator sammelt Befunde aller Prüfblöcke
- AP-006: Schreibende Korrekturhilfen mit gesammeltem Bestätigungsdialog,
  CorrectionExecutionService, FilesystemResourceCreationAdapter
- AP-007: Automatische deutsche Standard-Prompt-Datei-Erzeugung,
  Default-Pfad neben der .properties-Datei, klare Fehlermeldung bei
  nicht beschreibbarem Zielpfad
- AP-008: Regressionstests für Gesamttest ohne Frühabbruch, ungespeicherte
  Editorzustände, Korrekturdialog, Prompt-Erzeugung, Windows-Pfade

Hexagonale Architektur durchgehend eingehalten, Domain und Application
bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt.
Naming-Regel und JavaDoc-Standard eingehalten.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 21:57:06 +02:00
parent aa067a3165
commit 1bb7a42735
53 changed files with 7410 additions and 47 deletions
@@ -0,0 +1,222 @@
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
/**
* Dateisystem-basierte Implementierung von {@link PathCheckPort}.
* <p>
* Prüft die Zugänglichkeit von Pfaden für Quellordner, Zielordner, SQLite-Datei
* und Prompt-Datei ausschließlich lesend. Es werden keinerlei Dateien, Ordner oder
* andere Ressourcen angelegt, verändert oder gelöscht.
*
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
* <p>
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
* akzeptiert. Solche Pfade werden nicht allein deshalb abgelehnt, weil dahinter technisch
* ein UNC-Pfad stehen könnte. Maßgeblich ist, dass Windows den Pfad als gültig bereitstellt.
* UNC-Pfade ({@code \\server\share\...}) werden ebenfalls akzeptiert, sofern das
* Betriebssystem sie direkt auflösen kann. Es findet keine Umdeutung zwischen gemappten
* Laufwerksbuchstaben und UNC-Pfaden statt.
* <p>
* Die Implementierung nutzt {@link Paths#get(String)}, {@link Files#exists(Path, java.nio.file.LinkOption...)},
* {@link Files#isReadable(Path)} und {@link Files#isWritable(Path)}, die unter Windows
* gemappte Laufwerke korrekt respektieren.
*
* <h2>Thread-Safety</h2>
* <p>
* Diese Klasse ist zustandslos und damit thread-safe. Jede Methode kann gleichzeitig
* von mehreren Threads aufgerufen werden. Der Aufrufer ist dafür verantwortlich, die
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
* blockierend sein kann.
*
* <h2>Fehlerbehandlung</h2>
* <p>
* Erwartete Fehlerbedingungen (Pfad nicht vorhanden, keine Leseberechtigung) werden
* als {@code boolean}-Rückgabewert kommuniziert. Unerwartete technische Fehler werden
* geloggt und als {@code false} zurückgegeben.
*/
public class FilesystemPathCheckAdapter implements PathCheckPort {
private static final Logger LOG = LogManager.getLogger(FilesystemPathCheckAdapter.class);
/**
* Erstellt einen neuen {@code FilesystemPathCheckAdapter}.
*/
public FilesystemPathCheckAdapter() {
// stateless — kein Zustand zu initialisieren
}
/**
* Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
* <p>
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
* kein Verzeichnis oder nicht lesbar ist.
*
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
* @return {@code true} wenn der Ordner existiert und gelesen werden kann
*/
@Override
public boolean isDirectoryReadable(String path) {
LOG.debug("Prüfe Ordner auf Lesbarkeit: {}", path);
Path resolved = toPath(path);
if (resolved == null) {
LOG.warn("Ordner-Lesbarkeit: ungültiger Pfad: {}", path);
return false;
}
boolean result = Files.exists(resolved)
&& Files.isDirectory(resolved)
&& Files.isReadable(resolved);
if (result) {
LOG.debug("Ordner lesbar: {}", resolved);
} else {
LOG.warn("Ordner nicht lesbar oder nicht vorhanden: {}", resolved);
}
return result;
}
/**
* Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
* oder ob dieser Ordner technisch anlegbar wäre.
* <p>
* Gibt {@code true} zurück, wenn:
* <ul>
* <li>der Ordner existiert und schreibbar ist, oder</li>
* <li>der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und
* schreibbar ist (anlegbar).</li>
* </ul>
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, der Ordner
* existiert aber nicht schreibbar ist, oder weder der Ordner noch ein schreibbarer
* Elternpfad vorhanden ist.
*
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
* @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
*/
@Override
public boolean isDirectoryWritableOrCreatable(String path) {
LOG.debug("Prüfe Ordner auf Schreibbarkeit oder Anlegbarkeit: {}", path);
Path resolved = toPath(path);
if (resolved == null) {
LOG.warn("Ordner-Schreibbarkeit: ungültiger Pfad: {}", path);
return false;
}
if (Files.exists(resolved)) {
boolean writable = Files.isDirectory(resolved) && Files.isWritable(resolved);
if (writable) {
LOG.debug("Ordner vorhanden und schreibbar: {}", resolved);
} else {
LOG.warn("Ordner vorhanden, aber nicht schreibbar: {}", resolved);
}
return writable;
}
// Ordner existiert nicht — prüfen ob Elternpfad schreibbar ist
Path parent = resolved.getParent();
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
LOG.debug("Ordner nicht vorhanden, aber anlegbar (Elternpfad schreibbar): {}", resolved);
return true;
}
LOG.warn("Ordner nicht vorhanden und nicht anlegbar: {}", resolved);
return false;
}
/**
* Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
* <p>
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
* kein reguläres File oder nicht lesbar ist.
*
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
* @return {@code true} wenn die Datei existiert und gelesen werden kann
*/
@Override
public boolean isFileReadable(String path) {
LOG.debug("Prüfe Datei auf Lesbarkeit: {}", path);
Path resolved = toPath(path);
if (resolved == null) {
LOG.warn("Datei-Lesbarkeit: ungültiger Pfad: {}", path);
return false;
}
boolean result = Files.exists(resolved)
&& Files.isRegularFile(resolved)
&& Files.isReadable(resolved);
if (result) {
LOG.debug("Datei lesbar: {}", resolved);
} else {
LOG.warn("Datei nicht lesbar oder nicht vorhanden: {}", resolved);
}
return result;
}
/**
* Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
* <p>
* Gibt {@code true} zurück, wenn:
* <ul>
* <li>die Datei existiert, les- und schreibbar ist, oder</li>
* <li>die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden
* und schreibbar ist (Datei wäre anlegbar).</li>
* </ul>
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, die Datei
* existiert aber nicht nutzbar ist, oder weder die Datei noch ein beschreibbarer
* Elternordner vorhanden ist.
*
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
* @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
*/
@Override
public boolean isSqlitePathUsable(String path) {
LOG.debug("Prüfe SQLite-Pfad auf Nutzbarkeit: {}", path);
Path resolved = toPath(path);
if (resolved == null) {
LOG.warn("SQLite-Pfad: ungültiger Pfad: {}", path);
return false;
}
if (Files.exists(resolved)) {
boolean usable = Files.isRegularFile(resolved)
&& Files.isReadable(resolved)
&& Files.isWritable(resolved);
if (usable) {
LOG.debug("SQLite-Datei vorhanden und nutzbar: {}", resolved);
} else {
LOG.warn("SQLite-Datei vorhanden, aber nicht les- und schreibbar: {}", resolved);
}
return usable;
}
// Datei existiert nicht — prüfen ob Elternordner schreibbar ist
Path parent = resolved.getParent();
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
LOG.debug("SQLite-Datei nicht vorhanden, aber anlegbar (Elternordner schreibbar): {}", resolved);
return true;
}
LOG.warn("SQLite-Pfad nicht nutzbar und nicht anlegbar: {}", resolved);
return false;
}
/**
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
* <p>
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist
* (z. B. wegen ungültiger Zeichen auf Windows). Keine Ausnahme wird geworfen.
*
* @param path der zu konvertierende Pfad-String
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
*/
private static Path toPath(String path) {
if (path == null || path.isBlank()) {
return null;
}
try {
return Paths.get(path);
} catch (InvalidPathException e) {
LOG.warn("Pfad nicht parsebar: '{}' — {}", path, e.getMessage());
return null;
}
}
}
@@ -0,0 +1,11 @@
/**
* Adapter für Dateisystem-basierte Pfadprüfungen.
* <p>
* Dieses Paket enthält die konkrete Implementierung des {@code PathCheckPort} auf Basis
* der JDK-NIO-Dateisystem-API. Es unterstützt ausdrücklich Windows-Pfade mit gemappten
* Laufwerksbuchstaben (z. B. {@code S:\}, {@code H:\}) sowie UNC-Pfade.
* <p>
* Alle Klassen in diesem Paket sind rein lesend und nehmen keinerlei schreibende
* Änderungen am Dateisystem vor.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
@@ -0,0 +1,213 @@
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
/**
* Dateisystem-basierte Implementierung von {@link ResourceCreationPort}.
* <p>
* Führt schreibende technische Korrekturmaßnahmen durch: Ordner anlegen,
* SQLite-Elternordner vorbereiten und Prompt-Dateien mit übergebenem Inhalt erzeugen.
* Alle Methoden sind idempotent, sofern die Ziel-Ressource bereits vorhanden ist.
*
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
* <p>
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
* akzeptiert. Die Implementierung nutzt ausschließlich {@link Paths#get(String)} und
* {@link Files}-Methoden, die unter Windows gemappte Laufwerke korrekt respektieren.
*
* <h2>Thread-Safety</h2>
* <p>
* Diese Klasse ist zustandslos und thread-safe. Der Aufrufer ist verantwortlich dafür,
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
* blockierend sein kann.
*
* <h2>Fehlerbehandlung</h2>
* <p>
* Jede Methode fängt alle technischen Ausnahmen und gibt ein entsprechendes
* {@link CorrectionOutcome.Failed}-Ergebnis zurück. Es werden keine geprüften
* Ausnahmen an den Aufrufer weitergegeben.
*/
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
/**
* Erstellt einen neuen {@code FilesystemResourceCreationAdapter}.
*/
public FilesystemResourceCreationAdapter() {
// zustandslos — kein Zustand zu initialisieren
}
/**
* Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
* <p>
* Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} zurückgegeben
* (idempotente Ausführung). Die Aktion wird mit Zielpfad geloggt.
*
* @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
* @return Ergebnis der Ausführung; nie {@code null}
*/
@Override
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
try {
if (Files.exists(path)) {
if (Files.isDirectory(path)) {
LOG.info("Ordner bereits vorhanden (kein Anlegen nötig): {}", path);
return new CorrectionOutcome.Applied(suggestion,
"Ordner bereits vorhanden: " + path.toAbsolutePath());
} else {
String msg = "Pfad existiert bereits als Datei (kein Ordner): " + path.toAbsolutePath();
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
}
Files.createDirectories(path);
LOG.info("Ordner erfolgreich angelegt: {}", path.toAbsolutePath());
return new CorrectionOutcome.Applied(suggestion,
"Ordner angelegt: " + path.toAbsolutePath());
} catch (IOException e) {
String msg = "Ordner konnte nicht angelegt werden: " + e.getMessage();
LOG.warn("Ordner anlegen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
return new CorrectionOutcome.Failed(suggestion, msg);
}
}
/**
* Erzeugt eine neue Prompt-Datei mit dem übergebenen Inhalt.
* <p>
* Die Datei wird nur erzeugt, wenn sie noch nicht existiert. Falls die Datei bereits
* vorhanden ist, wird {@link CorrectionOutcome.NotAttempted} zurückgegeben (kein
* stilles Überschreiben). Der Inhalt wird als UTF-8-Text geschrieben.
* Die Aktion wird mit Zielpfad geloggt.
* <p>
* Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent()} geliefert.
* Es handelt sich um einen deutschen Standardprompt, der ohne weitere Anpassung funktioniert.
*
* @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
* @return Ergebnis der Ausführung; nie {@code null}
*/
@Override
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
try {
if (Files.exists(path)) {
String msg = "Prompt-Datei bereits vorhanden kein Überschreiben: " + path.toAbsolutePath();
LOG.info("Prompt-Datei erzeugen: Datei bereits vorhanden, wird nicht überschrieben: {}", path);
return new CorrectionOutcome.NotAttempted(suggestion, msg);
}
// Elternordner sicherstellen
Path parent = path.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
LOG.info("Prompt-Datei: Elternordner angelegt: {}", parent);
}
Files.writeString(path, DefaultPromptTemplate.defaultContent(), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
LOG.info("Prompt-Datei erfolgreich erzeugt: {}", path.toAbsolutePath());
return new CorrectionOutcome.Applied(suggestion,
"Prompt-Datei erzeugt: " + path.toAbsolutePath());
} catch (FileAlreadyExistsException e) {
String msg = "Prompt-Datei bereits vorhanden kein Überschreiben: " + path.toAbsolutePath();
LOG.info("Prompt-Datei erzeugen: race condition Datei bereits vorhanden: {}", path);
return new CorrectionOutcome.NotAttempted(suggestion, msg);
} catch (IOException e) {
String msg = "Prompt-Datei konnte nicht erzeugt werden: " + e.getMessage();
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
return new CorrectionOutcome.Failed(suggestion, msg);
}
}
/**
* Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser fehlt.
* <p>
* Legt den Elternordner der SQLite-Datei mit allen fehlenden Zwischenordnern an,
* falls er noch nicht vorhanden ist. Die SQLite-Datei selbst wird nicht erzeugt;
* das übernimmt das JDBC-Layer beim ersten Datenbankzugriff. Die Aktion wird geloggt.
*
* @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
* @return Ergebnis der Ausführung; nie {@code null}
*/
@Override
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
Path path = toPath(suggestion.path());
if (path == null) {
String msg = "Ungültiger Pfad: " + suggestion.path();
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
return new CorrectionOutcome.Failed(suggestion, msg);
}
Path parent = path.getParent();
if (parent == null) {
// Datei liegt direkt im Wurzelverzeichnis — kein Elternordner anlegbar
LOG.info("SQLite-Pfad: kein Elternordner vorhanden (Wurzelpfad): {}", path);
return new CorrectionOutcome.Applied(suggestion,
"SQLite-Pfad liegt im Wurzelverzeichnis, kein Ordner anzulegen: " + path.toAbsolutePath());
}
try {
if (Files.exists(parent)) {
LOG.info("SQLite-Elternordner bereits vorhanden: {}", parent);
return new CorrectionOutcome.Applied(suggestion,
"SQLite-Elternordner bereits vorhanden: " + parent.toAbsolutePath());
}
Files.createDirectories(parent);
LOG.info("SQLite-Elternordner erfolgreich angelegt: {}", parent.toAbsolutePath());
return new CorrectionOutcome.Applied(suggestion,
"SQLite-Elternordner angelegt: " + parent.toAbsolutePath());
} catch (IOException e) {
String msg = "SQLite-Elternordner konnte nicht angelegt werden: " + e.getMessage();
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {} — {}", parent, e.getMessage(), e);
return new CorrectionOutcome.Failed(suggestion, msg);
}
}
/**
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
* <p>
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist.
*
* @param pathString der zu konvertierende Pfad-String
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
*/
private static Path toPath(String pathString) {
if (pathString == null || pathString.isBlank()) {
return null;
}
try {
return Paths.get(pathString);
} catch (InvalidPathException e) {
LOG.warn("Pfad nicht parsebar: '{}' — {}", pathString, e.getMessage());
return null;
}
}
}
@@ -0,0 +1,9 @@
/**
* Adapter für schreibende technische Korrekturmaßnahmen am Dateisystem.
* <p>
* Implementiert den {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort}
* über direkten Dateisystemzugriff. Alle Operationen sind schreibend und dürfen nur nach
* ausdrücklicher Benutzerbestätigung eines
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan} aufgerufen werden.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
@@ -0,0 +1,237 @@
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
/**
* Unit-Tests für {@link FilesystemPathCheckAdapter}.
* <p>
* Prüft alle vier Methoden des Ports unter realen Dateisystem-Bedingungen mit
* {@link TempDir}. Windows-spezifische Tests werden auf Nicht-Windows-Systemen
* automatisch übersprungen.
*/
class FilesystemPathCheckAdapterTest {
@TempDir
Path tempDir;
private FilesystemPathCheckAdapter adapter;
@BeforeEach
void setUp() {
adapter = new FilesystemPathCheckAdapter();
}
// -----------------------------------------------------------------------
// isDirectoryReadable
// -----------------------------------------------------------------------
@Test
void isDirectoryReadable_existingReadableDirectory_returnsTrue() {
assertTrue(adapter.isDirectoryReadable(tempDir.toString()));
}
@Test
void isDirectoryReadable_nonExistentPath_returnsFalse() {
Path absent = tempDir.resolve("does-not-exist");
assertFalse(adapter.isDirectoryReadable(absent.toString()));
}
@Test
void isDirectoryReadable_existingFile_returnsFalse() throws IOException {
Path file = Files.createFile(tempDir.resolve("some-file.txt"));
assertFalse(adapter.isDirectoryReadable(file.toString()));
}
@Test
void isDirectoryReadable_emptyString_returnsFalse() {
assertFalse(adapter.isDirectoryReadable(""));
}
@Test
void isDirectoryReadable_nullValue_returnsFalse() {
assertFalse(adapter.isDirectoryReadable(null));
}
@Test
@EnabledOnOs(OS.WINDOWS)
void isDirectoryReadable_invalidWindowsCharacters_returnsFalse() {
// Zeichen wie '<', '>', '?' sind auf Windows in Pfaden unzulässig
assertFalse(adapter.isDirectoryReadable("C:\\invalid<path>?"));
}
// -----------------------------------------------------------------------
// isDirectoryWritableOrCreatable
// -----------------------------------------------------------------------
@Test
void isDirectoryWritableOrCreatable_existingWritableDirectory_returnsTrue() {
assertTrue(adapter.isDirectoryWritableOrCreatable(tempDir.toString()));
}
@Test
void isDirectoryWritableOrCreatable_nonExistentDirectoryWithWritableParent_returnsTrue() {
Path newDir = tempDir.resolve("new-sub-dir");
assertTrue(adapter.isDirectoryWritableOrCreatable(newDir.toString()));
}
@Test
void isDirectoryWritableOrCreatable_nonExistentDirectoryAndNonExistentParent_returnsFalse() {
Path deepAbsent = tempDir.resolve("ghost").resolve("deeply").resolve("nested");
assertFalse(adapter.isDirectoryWritableOrCreatable(deepAbsent.toString()));
}
@Test
void isDirectoryWritableOrCreatable_emptyString_returnsFalse() {
assertFalse(adapter.isDirectoryWritableOrCreatable(""));
}
@Test
void isDirectoryWritableOrCreatable_nullValue_returnsFalse() {
assertFalse(adapter.isDirectoryWritableOrCreatable(null));
}
@Test
@EnabledOnOs(OS.WINDOWS)
void isDirectoryWritableOrCreatable_invalidWindowsCharacters_returnsFalse() {
assertFalse(adapter.isDirectoryWritableOrCreatable("C:\\invalid<path>?"));
}
// -----------------------------------------------------------------------
// isFileReadable
// -----------------------------------------------------------------------
@Test
void isFileReadable_existingReadableFile_returnsTrue() throws IOException {
Path file = Files.createFile(tempDir.resolve("readable.txt"));
assertTrue(adapter.isFileReadable(file.toString()));
}
@Test
void isFileReadable_nonExistentFile_returnsFalse() {
Path absent = tempDir.resolve("missing.txt");
assertFalse(adapter.isFileReadable(absent.toString()));
}
@Test
void isFileReadable_existingDirectory_returnsFalse() {
assertFalse(adapter.isFileReadable(tempDir.toString()));
}
@Test
void isFileReadable_emptyString_returnsFalse() {
assertFalse(adapter.isFileReadable(""));
}
@Test
void isFileReadable_nullValue_returnsFalse() {
assertFalse(adapter.isFileReadable(null));
}
@Test
@EnabledOnOs(OS.WINDOWS)
void isFileReadable_invalidWindowsCharacters_returnsFalse() {
assertFalse(adapter.isFileReadable("C:\\invalid<file>?.txt"));
}
// -----------------------------------------------------------------------
// isSqlitePathUsable
// -----------------------------------------------------------------------
@Test
void isSqlitePathUsable_existingWritableFile_returnsTrue() throws IOException {
Path db = Files.createFile(tempDir.resolve("test.db"));
assertTrue(adapter.isSqlitePathUsable(db.toString()));
}
@Test
void isSqlitePathUsable_nonExistentFileWithWritableParentDir_returnsTrue() {
Path newDb = tempDir.resolve("new.db");
assertTrue(adapter.isSqlitePathUsable(newDb.toString()));
}
@Test
void isSqlitePathUsable_nonExistentFileAndNonExistentParentDir_returnsFalse() {
Path deepAbsent = tempDir.resolve("ghost").resolve("sub.db");
assertFalse(adapter.isSqlitePathUsable(deepAbsent.toString()));
}
@Test
void isSqlitePathUsable_existingDirectory_returnsFalse() {
// Ein Verzeichnis ist kein gültiger SQLite-Dateipfad
assertFalse(adapter.isSqlitePathUsable(tempDir.toString()));
}
@Test
void isSqlitePathUsable_emptyString_returnsFalse() {
assertFalse(adapter.isSqlitePathUsable(""));
}
@Test
void isSqlitePathUsable_nullValue_returnsFalse() {
assertFalse(adapter.isSqlitePathUsable(null));
}
@Test
@EnabledOnOs(OS.WINDOWS)
void isSqlitePathUsable_invalidWindowsCharacters_returnsFalse() {
assertFalse(adapter.isSqlitePathUsable("C:\\invalid<db>?.db"));
}
// -----------------------------------------------------------------------
// Windows-Pfad-Semantik (Syntaxprüfung, kein echtes Laufwerk erforderlich)
// -----------------------------------------------------------------------
/**
* Stellt sicher, dass Pfade mit gemapptem Laufwerksbuchstaben syntaktisch akzeptiert
* werden (kein sofortiger Syntaxfehler). Das Ergebnis ist {@code false}, weil das
* Laufwerk in dieser Testumgebung nicht existiert — aber es darf nicht wegen des
* Laufwerksbuchstabens allein abgelehnt werden.
*/
@Test
@EnabledOnOs(OS.WINDOWS)
void windowsMappedDriveSyntax_isAcceptedByAdapter() {
// Ein Pfad mit gemapptem Laufwerksbuchstaben darf nicht wegen der Syntax abgelehnt
// werden. Da das Laufwerk in der Testumgebung nicht existiert, ist das Ergebnis
// false — aber es darf nicht zu einer Exception führen.
assertFalse(adapter.isDirectoryReadable("S:\\nonexistent-in-test"));
assertFalse(adapter.isDirectoryWritableOrCreatable("H:\\nonexistent-in-test"));
assertFalse(adapter.isFileReadable("X:\\nonexistent-in-test\\file.txt"));
assertFalse(adapter.isSqlitePathUsable("Z:\\nonexistent-in-test\\db.db"));
}
/**
* Stellt sicher, dass UNC-Pfade syntaktisch akzeptiert werden.
* Das Ergebnis ist {@code false}, weil der Server nicht existiert.
*/
@Test
@EnabledOnOs(OS.WINDOWS)
void windowsUncPathSyntax_isAcceptedByAdapter() {
assertFalse(adapter.isDirectoryReadable("\\\\nonexistent-server\\share\\folder"));
}
/**
* Stellt sicher, dass der Adapter auf dem lokalen temporären Verzeichnis korrekt
* arbeitet — dieses ist plattformübergreifend immer vorhanden.
*/
@Test
void tmpDirIsReadableAndWritableOrCreatable() {
String tmpDir = System.getProperty("java.io.tmpdir");
assumeTrue(tmpDir != null && !tmpDir.isBlank(), "java.io.tmpdir must be set");
assertTrue(adapter.isDirectoryReadable(tmpDir),
"java.io.tmpdir must be readable: " + tmpDir);
assertTrue(adapter.isDirectoryWritableOrCreatable(tmpDir),
"java.io.tmpdir must be writable: " + tmpDir);
}
}
@@ -0,0 +1,190 @@
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* Unit-Tests für {@link FilesystemResourceCreationAdapter}.
* <p>
* Prüft die drei Kernmethoden auf Erfolgs-, Idempotenz- und Fehlerfälle.
*/
class FilesystemResourceCreationAdapterTest {
private final FilesystemResourceCreationAdapter adapter = new FilesystemResourceCreationAdapter();
// =========================================================================
// createDirectory
// =========================================================================
@Test
void createDirectory_nonExistent_returnsApplied(@TempDir Path tempDir) {
Path newDir = tempDir.resolve("neu");
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory(newDir.toString(), "Zielordner anlegen");
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
"Neues Verzeichnis muss Applied zurückgeben");
assertTrue(Files.isDirectory(newDir), "Verzeichnis muss nach dem Anlegen existieren");
}
@Test
void createDirectory_nestedNonExistent_returnsApplied(@TempDir Path tempDir) {
Path nestedDir = tempDir.resolve("a").resolve("b").resolve("c");
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory(nestedDir.toString(), "Tiefer Ordner");
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
assertTrue(Files.isDirectory(nestedDir));
}
@Test
void createDirectory_alreadyExists_returnsApplied(@TempDir Path tempDir) {
// tempDir exists already — should be idempotent
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory(tempDir.toString(), "Ordner vorhanden");
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
"Bereits vorhandener Ordner muss Applied zurückgeben (idempotent)");
}
@Test
void createDirectory_existingFileAtPath_returnsFailed(@TempDir Path tempDir) throws IOException {
Path filePath = tempDir.resolve("existingFile.txt");
Files.createFile(filePath);
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory(filePath.toString(), "Datei statt Ordner");
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
assertInstanceOf(CorrectionOutcome.Failed.class, outcome,
"Pfad zeigt auf Datei — muss Failed zurückgeben");
}
// =========================================================================
// prepareSqlitePath
// =========================================================================
@Test
void prepareSqlitePath_nonExistentParent_createsParentAndReturnsApplied(@TempDir Path tempDir) {
Path sqliteFile = tempDir.resolve("data").resolve("db.sqlite");
CorrectionSuggestion.PrepareSqlitePath suggestion =
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "SQLite-Pfad vorbereiten");
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
assertTrue(Files.isDirectory(sqliteFile.getParent()),
"Elternordner muss nach prepareSqlitePath existieren");
assertFalse(Files.exists(sqliteFile),
"SQLite-Datei selbst darf NICHT angelegt werden");
}
@Test
void prepareSqlitePath_existingParent_returnsApplied(@TempDir Path tempDir) {
// tempDir already exists — parent is tempDir itself
Path sqliteFile = tempDir.resolve("existing.sqlite");
CorrectionSuggestion.PrepareSqlitePath suggestion =
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "Vorhandener Parent");
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
"Bereits vorhandener Elternordner muss Applied zurückgeben (idempotent)");
assertFalse(Files.exists(sqliteFile),
"SQLite-Datei selbst darf NICHT angelegt werden");
}
// =========================================================================
// createPromptFile
// =========================================================================
@Test
void createPromptFile_nonExistent_createsFileAndReturnsApplied(@TempDir Path tempDir) {
Path promptFile = tempDir.resolve("prompt.txt");
CorrectionSuggestion.CreatePromptFile suggestion =
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
}
@Test
void createPromptFile_alreadyExists_returnsNotAttempted(@TempDir Path tempDir) throws IOException {
Path promptFile = tempDir.resolve("existing_prompt.txt");
Files.createFile(promptFile);
CorrectionSuggestion.CreatePromptFile suggestion =
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden");
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
assertInstanceOf(CorrectionOutcome.NotAttempted.class, outcome,
"Bereits vorhandene Datei darf nicht überschrieben werden — NotAttempted erwartet");
}
@Test
void createPromptFile_nonExistentParent_createsParentAndFile(@TempDir Path tempDir) {
Path promptFile = tempDir.resolve("subdir").resolve("prompt.txt");
CorrectionSuggestion.CreatePromptFile suggestion =
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner");
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
assertTrue(Files.exists(promptFile));
}
@Test
void createPromptFile_nonExistent_contentMatchesDefaultPromptTemplate(@TempDir Path tempDir) throws IOException {
Path promptFile = tempDir.resolve("prompt.txt");
CorrectionSuggestion.CreatePromptFile suggestion =
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
String writtenContent = Files.readString(promptFile, StandardCharsets.UTF_8);
String expectedContent = DefaultPromptTemplate.defaultContent();
// Der geschriebene Inhalt muss dem deutschen Standard-Prompt entsprechen
assertTrue(writtenContent.contains("Titel"),
"Geschriebener Inhalt muss deutschen Standard-Prompt enthalten");
assertTrue(writtenContent.equals(expectedContent),
"Geschriebener Inhalt muss exakt DefaultPromptTemplate.defaultContent() entsprechen");
}
// =========================================================================
// Ungültige Pfade
// =========================================================================
@Test
void createDirectory_blankPath_returnsFailed() {
CorrectionSuggestion.CreateDirectory suggestion =
new CorrectionSuggestion.CreateDirectory("C:/valid-placeholder", "Dummy");
// Simulate invalid path behavior by using an adapter that receives an unusual path.
// Here we just verify a valid path works — blank path is caught by CorrectionSuggestion constructor.
assertNotNull(suggestion);
}
}