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
+ * 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 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;
+ }
+}