diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java index 352d2d5..bbebafc 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java @@ -215,7 +215,10 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort { status, failure_class, failure_message, retryable, ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count, ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title, - final_target_file_name + final_target_file_name, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + price_input_per_token_nano_usd, price_output_per_token_nano_usd FROM processing_attempt WHERE fingerprint = ? ORDER BY attempt_number ASC diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapterTest.java new file mode 100644 index 0000000..f6be1a7 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapterTest.java @@ -0,0 +1,163 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.RunId; + +/** + * Tests für {@link SqliteHistoryQueryAdapter#findAttemptsByFingerprint}. + * + *

Deckt insbesondere die Regression ab, dass der Historien-Leseadapter die + * Token- und Preis-Snapshot-Spalten mappen kann. Die zugehörige SELECT-Liste muss + * dieselben Spalten projizieren, die der Mapper liest – andernfalls schlägt jede + * Detailabfrage mit {@code no such column: 'input_tokens'} fehl. + */ +class SqliteHistoryQueryAdapterTest { + + private String jdbcUrl; + private SqliteSchemaInitializationAdapter schemaAdapter; + private SqliteProcessingAttemptRepositoryAdapter repository; + private SqliteHistoryQueryAdapter historyQuery; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + Path dbFile = tempDir.resolve("history-query-test.db"); + jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath(); + schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl); + repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + historyQuery = new SqliteHistoryQueryAdapter(jdbcUrl); + schemaAdapter.initializeSchema(); + } + + /** + * Ein Versuch mit gesetzten Token- und Preis-Werten muss ohne Exception geladen und + * die Werte müssen korrekt zurückgemappt werden. + */ + @Test + void findAttemptsByFingerprint_liestTokenUndPreisSpalten() { + DocumentFingerprint fp = fingerprint("aa"); + insertDocumentRecord(fp); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + ProcessingAttempt attempt = new ProcessingAttempt( + fp, new RunId("run-tokens"), 1, now, now.plusSeconds(1), + ProcessingStatus.PROPOSAL_READY, + null, null, false, + "claude", + "claude-haiku-4-5-20251001", "prompt-v1", 3, 4200, + "{\"title\":\"x\"}", "reasoning", + null, null, "Titel", "2026-07-01 - Titel.pdf", + 1500L, 800L, 200L, 100L, 250L, 2000L); + repository.save(attempt); + + List loaded = historyQuery.findAttemptsByFingerprint(fp); + + assertThat(loaded).hasSize(1); + ProcessingAttempt result = loaded.get(0); + assertThat(result.inputTokens()).isEqualTo(1500L); + assertThat(result.outputTokens()).isEqualTo(800L); + assertThat(result.cacheCreationInputTokens()).isEqualTo(200L); + assertThat(result.cacheReadInputTokens()).isEqualTo(100L); + assertThat(result.priceInputPerTokenNanoUsd()).isEqualTo(250L); + assertThat(result.priceOutputPerTokenNanoUsd()).isEqualTo(2000L); + } + + /** + * Ein Versuch ohne Token-/Preis-Werte (typisch für Skip-/Pre-Check-Pfade und + * historische Bestände) muss {@code null} für alle Token-Felder zurückliefern, + * ohne dass die Abfrage fehlschlägt. + */ + @Test + void findAttemptsByFingerprint_nullTokenWerteBleibenNull() { + DocumentFingerprint fp = fingerprint("bb"); + insertDocumentRecord(fp); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields( + fp, new RunId("run-null"), 1, now, now.plusSeconds(1), + ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true); + repository.save(attempt); + + List loaded = historyQuery.findAttemptsByFingerprint(fp); + + assertThat(loaded).hasSize(1); + ProcessingAttempt result = loaded.get(0); + assertThat(result.inputTokens()).isNull(); + assertThat(result.outputTokens()).isNull(); + assertThat(result.cacheCreationInputTokens()).isNull(); + assertThat(result.cacheReadInputTokens()).isNull(); + assertThat(result.priceInputPerTokenNanoUsd()).isNull(); + assertThat(result.priceOutputPerTokenNanoUsd()).isNull(); + } + + /** + * Mehrere Versuche müssen aufsteigend nach {@code attempt_number} zurückgegeben werden. + */ + @Test + void findAttemptsByFingerprint_sortiertAufsteigend() { + DocumentFingerprint fp = fingerprint("cc"); + insertDocumentRecord(fp); + + Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS); + repository.save(ProcessingAttempt.withoutAiFields( + fp, new RunId("run-1"), 1, base, base.plusSeconds(1), + ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true)); + repository.save(new ProcessingAttempt( + fp, new RunId("run-2"), 2, base.plusSeconds(10), base.plusSeconds(11), + ProcessingStatus.PROPOSAL_READY, + null, null, false, "claude", + "claude-haiku-4-5-20251001", "prompt-v1", 1, 100, + "{}", "r", null, null, "T", "2026-07-01 - T.pdf", + 10L, 20L, null, null, 250L, 2000L)); + + List loaded = historyQuery.findAttemptsByFingerprint(fp); + + assertThat(loaded).hasSize(2); + assertThat(loaded.get(0).attemptNumber()).isEqualTo(1); + assertThat(loaded.get(1).attemptNumber()).isEqualTo(2); + assertThat(loaded.get(1).inputTokens()).isEqualTo(10L); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void insertDocumentRecord(DocumentFingerprint fp) { + try (Connection conn = DriverManager.getConnection(jdbcUrl); + PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO document_record + (fingerprint, last_known_source_locator, last_known_source_file_name, + overall_status, created_at, updated_at) + VALUES (?, '/tmp/test.pdf', 'test.pdf', 'READY_FOR_AI', + strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))""")) { + ps.setString(1, fp.sha256Hex()); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("Failed to insert test document record", e); + } + } + + private static DocumentFingerprint fingerprint(String suffix) { + return new DocumentFingerprint("0".repeat(64 - suffix.length()) + suffix); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java index 9977157..8726657 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java @@ -43,9 +43,11 @@ public class PdfUmbenennerApplication { * @param args command-line arguments; see class JavaDoc for supported options */ public static void main(String[] args) { - EarlyLogDirectoryInitializer.applyFromArgs(args); + EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup = + EarlyLogDirectoryInitializer.applyFromArgs(args); Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class); LOG.info("Starting PDF Umbenenner application..."); + logEffectiveLogDirectory(LOG, logSetup); try { StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args); @@ -74,4 +76,31 @@ public class PdfUmbenennerApplication { System.exit(1); } } + + /** + * Protokolliert nach der Log4j2-Initialisierung sichtbar, welches Log-Verzeichnis + * effektiv genutzt wird. + *

+ * Musste die Anwendung wegen eines nicht anlegbaren konfigurierten Verzeichnisses auf + * das Standard-Log-Verzeichnis ausweichen, wird dies als Warnung mit konfiguriertem und + * tatsächlichem Pfad ausgegeben, damit der frühere stille Fehlschlag nachvollziehbar ist. + * Diese erste Logzeile landet im effektiv genutzten Log und macht den Speicherort auffindbar. + * + * @param log der bereits initialisierte Logger; darf nicht {@code null} sein + * @param logSetup das Ergebnis der frühen Log-Verzeichnis-Auflösung; darf nicht {@code null} sein + */ + private static void logEffectiveLogDirectory(Logger log, + EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup) { + String fallbackDirectory = System.getProperty("user.home") + "/pdf-umbenenner/logs"; + if (logSetup.fallbackUsed()) { + log.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht angelegt oder genutzt werden – " + + "Ausweichen auf Standard-Log-Verzeichnis '{}'.", + logSetup.configuredValue(), fallbackDirectory); + } else if (logSetup.appliedDirectory() != null) { + log.info("Effektives Log-Verzeichnis: {}", logSetup.appliedDirectory()); + } else { + log.info("Kein Log-Verzeichnis konfiguriert – Standard-Log-Verzeichnis '{}' wird genutzt.", + fallbackDirectory); + } + } } diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializer.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializer.java index 1854db0..dc41823 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializer.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializer.java @@ -1,7 +1,8 @@ package de.gecheckt.pdf.umbenenner.bootstrap.startup; import java.io.IOException; -import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -19,9 +20,22 @@ import java.util.Properties; * Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter * {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht. *

- * Diese Klasse vermeidet bewusst jede Logger-Nutzung und schluckt sämtliche Fehler: - * Sie soll niemals den Programmstart verhindern, sondern lediglich einen frühen - * Best-Effort-Hinweis an Log4j2 liefern. + * Backslash-Behandlung: Wie der reguläre Konfigurationsleser werden + * Backslashes vor dem {@link Properties}-Laden verdoppelt, damit Windows-Pfade wie + * {@code C:\Users\...\logs} nicht durch die Escape-Interpretation von + * {@link Properties} verstümmelt werden. Der aufgelöste Pfad wird zusätzlich auf + * Forward-Slashes normalisiert. + *

+ * Verzeichnisanlage: Das konfigurierte Log-Verzeichnis wird per + * Best-Effort mit {@link Files#createDirectories(Path, java.nio.file.attribute.FileAttribute[])} + * angelegt. Gelingt das nicht (z. B. fehlende Rechte oder ungültiger Pfad), bleibt die + * System-Property ungesetzt, sodass der in {@code log4j2.xml} hinterlegte, garantiert + * beschreibbare Fallback greift. Der Start wird niemals verhindert. + *

+ * Diese Klasse vermeidet bewusst jede Logger-Nutzung (sie läuft vor der Log4j2-Init). + * Das Ergebnis wird als {@link LogDirectorySetupResult} zurückgegeben, damit der Aufrufer + * nach erfolgter Log4j2-Initialisierung eine sichtbare Meldung über das effektive + * Log-Verzeichnis bzw. einen genutzten Fallback protokollieren kann. */ public final class EarlyLogDirectoryInitializer { @@ -34,28 +48,82 @@ public final class EarlyLogDirectoryInitializer { // utility } + /** + * Ergebnis der frühen Log-Verzeichnis-Auflösung. + *

+ * Dient ausschließlich der nachgelagerten, sichtbaren Protokollierung nach der + * Log4j2-Initialisierung. Er trifft keine weitere Entscheidung. + * + * @param configuredValue der aus der Konfiguration aufgelöste, normalisierte Wert von + * {@code log.directory}, oder {@code null}, wenn keiner ermittelt + * werden konnte + * @param appliedDirectory das tatsächlich als System-Property gesetzte und angelegte + * Verzeichnis, oder {@code null}, wenn kein konfiguriertes + * Verzeichnis angewandt wurde (Fallback greift) + * @param fallbackUsed {@code true}, wenn ein konfiguriertes Log-Verzeichnis vorlag, + * aber nicht angelegt/genutzt werden konnte und deshalb auf das + * Standard-Log-Verzeichnis ausgewichen wird + */ + public record LogDirectorySetupResult(String configuredValue, String appliedDirectory, + boolean fallbackUsed) { + + private static LogDirectorySetupResult notConfigured() { + return new LogDirectorySetupResult(null, null, false); + } + + private static LogDirectorySetupResult applied(String configuredValue, String appliedDirectory) { + return new LogDirectorySetupResult(configuredValue, appliedDirectory, false); + } + + private static LogDirectorySetupResult fallback(String configuredValue) { + return new LogDirectorySetupResult(configuredValue, null, true); + } + } + /** * Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory} - * zu lesen, und setzt ihn als System-Property, sofern er ein nicht-leerer String ist. + * zu lesen, das Verzeichnis anzulegen und den Pfad als System-Property zu setzen. *

* Greift {@code --config } auf, ansonsten {@code config/application.properties} - * relativ zum Arbeitsverzeichnis. Ist kein Wert ableitbar, bleibt die System-Property - * unverändert; in diesem Fall greift der in {@code log4j2.xml} hinterlegte Fallback. + * relativ zum Arbeitsverzeichnis. Ist bereits eine System-Property gesetzt, wird sie + * respektiert. Kann kein Wert ermittelt oder das Verzeichnis nicht angelegt werden, + * bleibt die System-Property ungesetzt; in diesem Fall greift der in {@code log4j2.xml} + * hinterlegte Fallback. * * @param args Kommandozeilenargumente, dürfen {@code null} sein + * @return das Ergebnis der Auflösung für die nachgelagerte, sichtbare Protokollierung; + * nie {@code null} */ - public static void applyFromArgs(String[] args) { + public static LogDirectorySetupResult applyFromArgs(String[] args) { + if (isLogPropertyAlreadySet()) { + return LogDirectorySetupResult.applied(null, System.getProperty(SYSTEM_PROPERTY_KEY)); + } + + String configuredValue = null; try { - if (isLogPropertyAlreadySet()) { - return; - } Path configPath = resolveConfigPath(args); if (configPath == null || !Files.isRegularFile(configPath)) { - return; + return LogDirectorySetupResult.notConfigured(); } - applyLogDirectoryFromConfig(configPath); - } catch (IOException | RuntimeException ignored) { - // bewusst still: Log4j2-Fallback aus log4j2.xml übernimmt ansonsten + configuredValue = readLogDirectoryValue(configPath); + if (configuredValue == null || configuredValue.isBlank()) { + return LogDirectorySetupResult.notConfigured(); + } + + Path directory = Paths.get(configuredValue); + Files.createDirectories(directory); + String applied = directory.toString(); + System.setProperty(SYSTEM_PROPERTY_KEY, applied); + return LogDirectorySetupResult.applied(configuredValue, applied); + } catch (IOException | RuntimeException e) { + // Bewusst still: kein Logger verfügbar. Bei bekanntem, aber nicht nutzbarem + // konfiguriertem Wert wird ein Fallback signalisiert, damit der Aufrufer nach + // der Log4j2-Init eine sichtbare Warnung ausgeben kann. Die System-Property + // bleibt ungesetzt, sodass der log4j2.xml-Fallback greift. + if (configuredValue != null && !configuredValue.isBlank()) { + return LogDirectorySetupResult.fallback(configuredValue); + } + return LogDirectorySetupResult.notConfigured(); } } @@ -64,15 +132,26 @@ public final class EarlyLogDirectoryInitializer { return val != null && !val.isBlank(); } - private static void applyLogDirectoryFromConfig(Path configPath) throws IOException { + /** + * Liest den Wert von {@code log.directory} aus der Konfigurationsdatei, verdoppelt zuvor + * Backslashes (analog zum regulären Konfigurationsleser) und normalisiert den Pfad auf + * Forward-Slashes. + * + * @param configPath Pfad zur Konfigurationsdatei; darf nicht {@code null} sein + * @return der normalisierte Wert, oder {@code null}, wenn nicht vorhanden oder leer + * @throws IOException wenn die Datei nicht gelesen werden kann + */ + private static String readLogDirectoryValue(Path configPath) throws IOException { + String content = Files.readString(configPath, StandardCharsets.UTF_8); + // Backslashes verdoppeln, sonst interpretiert Properties z. B. \U/\D/\t als Escape. + String escapedContent = content.replace("\\", "\\\\"); Properties properties = new Properties(); - try (InputStream in = Files.newInputStream(configPath)) { - properties.load(in); - } + properties.load(new StringReader(escapedContent)); String value = properties.getProperty(CONFIG_PROPERTY_KEY); - if (value != null && !value.isBlank()) { - System.setProperty(SYSTEM_PROPERTY_KEY, value.trim()); + if (value == null || value.isBlank()) { + return null; } + return value.trim().replace('\\', '/'); } private static Path resolveConfigPath(String[] args) { diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializerTest.java new file mode 100644 index 0000000..03b2fea --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/EarlyLogDirectoryInitializerTest.java @@ -0,0 +1,166 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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 java.nio.file.Paths; + +import org.junit.jupiter.api.AfterEach; +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; + +import de.gecheckt.pdf.umbenenner.bootstrap.startup.EarlyLogDirectoryInitializer.LogDirectorySetupResult; + +/** + * Tests für {@link EarlyLogDirectoryInitializer}. + * + *

Deckt insbesondere die Regression ab, dass Windows-Pfade mit Backslashes nicht durch + * die Escape-Interpretation von {@link java.util.Properties} verstümmelt werden, sowie das + * Best-Effort-Anlegen des Verzeichnisses und den sichtbaren Fallback, wenn das konfigurierte + * Verzeichnis nicht nutzbar ist. + * + *

Die System-Property {@code log.directory} ist globaler Prozesszustand; sie wird pro Test + * gesichert, geleert und anschließend wiederhergestellt. + */ +class EarlyLogDirectoryInitializerTest { + + private static final String KEY = "log.directory"; + + private String originalProperty; + + @TempDir + Path tempDir; + + @BeforeEach + void clearLogDirectoryProperty() { + originalProperty = System.getProperty(KEY); + System.clearProperty(KEY); + } + + @AfterEach + void restoreLogDirectoryProperty() { + if (originalProperty == null) { + System.clearProperty(KEY); + } else { + System.setProperty(KEY, originalProperty); + } + } + + /** + * Regression: Ein Windows-Pfad mit einfachen Backslashes darf beim frühen Einlesen nicht + * verstümmelt werden; das Verzeichnis muss angelegt und exakt referenziert werden. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void windowsBackslashPfad_wirdNichtVerstuemmeltUndAngelegt() throws IOException { + Path expected = tempDir.resolve("winlogs"); + String backslashValue = expected.toString().replace('/', '\\'); + Path config = writeConfig("log.directory=" + backslashValue); + + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs( + new String[]{"--config", config.toString()}); + + assertFalse(result.fallbackUsed(), "Ein anlegbares Verzeichnis darf keinen Fallback auslösen"); + assertTrue(Files.isDirectory(expected), "Konfiguriertes Log-Verzeichnis muss angelegt worden sein"); + String property = System.getProperty(KEY); + assertNotNull(property, "System-Property log.directory muss gesetzt sein"); + assertTrue(Files.isSameFile(expected, Paths.get(property)), + "System-Property muss auf das konfigurierte Verzeichnis zeigen, nicht auf einen verstümmelten Pfad"); + } + + /** + * Ein noch nicht existentes, aber anlegbares Verzeichnis wird angelegt und als + * System-Property gesetzt. + */ + @Test + void anlegbaresVerzeichnis_wirdAngelegtUndGesetzt() throws IOException { + Path expected = tempDir.resolve("logs-sub"); + String value = expected.toString().replace('\\', '/'); + Path config = writeConfig("log.directory=" + value); + + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs( + new String[]{"--config", config.toString()}); + + assertFalse(result.fallbackUsed()); + assertTrue(Files.isDirectory(expected)); + String property = System.getProperty(KEY); + assertNotNull(property); + assertTrue(Files.isSameFile(expected, Paths.get(property))); + } + + /** + * Ein nicht anlegbares Verzeichnis (Unterverzeichnis unterhalb einer regulären Datei) + * darf keine System-Property setzen und muss den Fallback signalisieren. + */ + @Test + void nichtAnlegbaresVerzeichnis_signalisiertFallbackOhneProperty() throws IOException { + Path blockingFile = tempDir.resolve("blocker.txt"); + Files.writeString(blockingFile, "x", StandardCharsets.UTF_8); + String value = blockingFile.toString().replace('\\', '/') + "/sub"; + Path config = writeConfig("log.directory=" + value); + + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs( + new String[]{"--config", config.toString()}); + + assertTrue(result.fallbackUsed(), "Nicht anlegbares Verzeichnis muss Fallback signalisieren"); + assertNull(System.getProperty(KEY), "Bei Fallback darf keine System-Property gesetzt sein"); + } + + /** + * Ist {@code log.directory} nicht konfiguriert, bleibt die System-Property ungesetzt und es + * wird kein Fallback signalisiert. + */ + @Test + void keinLogVerzeichnis_setztKeineProperty() throws IOException { + Path config = writeConfig("source.folder=/tmp"); + + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs( + new String[]{"--config", config.toString()}); + + assertFalse(result.fallbackUsed()); + assertNull(result.appliedDirectory()); + assertNull(System.getProperty(KEY)); + } + + /** + * Eine bereits gesetzte System-Property wird respektiert und nicht überschrieben. + */ + @Test + void bereitsGesetztePropertyWirdRespektiert() { + System.setProperty(KEY, "C:/vorgegeben/logs"); + + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(new String[]{}); + + assertFalse(result.fallbackUsed()); + assertEquals("C:/vorgegeben/logs", result.appliedDirectory()); + assertEquals("C:/vorgegeben/logs", System.getProperty(KEY)); + } + + /** + * Zeigt {@code --config} auf eine nicht existente Datei, bleibt die System-Property ungesetzt. + */ + @Test + void fehlendeKonfigdatei_setztKeineProperty() { + LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs( + new String[]{"--config", tempDir.resolve("does-not-exist.properties").toString()}); + + assertFalse(result.fallbackUsed()); + assertNull(System.getProperty(KEY)); + } + + private Path writeConfig(String content) throws IOException { + Path config = tempDir.resolve("application.properties"); + Files.writeString(config, content + "\n", StandardCharsets.UTF_8); + return config; + } +}