From 5099ff4acaa8968cb56c8f42d066e7c2e725d6c6 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Thu, 9 Apr 2026 05:42:02 +0200 Subject: [PATCH] =?UTF-8?q?V1.1=20=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/application-local.example.properties | 72 +- config/application-test.example.properties | 73 +- docs/betrieb.md | 107 ++- docs/workpackages/V1.1 - Abschlussnachweis.md | 149 ++++ .../out/ai/AnthropicClaudeHttpAdapter.java | 394 +++++++++++ .../adapter/out/ai/OpenAiHttpAdapter.java | 49 +- .../StartConfigurationValidator.java | 34 +- .../LegacyConfigurationMigrator.java | 306 +++++++++ .../MultiProviderConfigurationParser.java | 203 ++++++ .../MultiProviderConfigurationValidator.java | 106 +++ .../PropertiesConfigurationPortAdapter.java | 69 +- ...iteProcessingAttemptRepositoryAdapter.java | 37 +- .../SqliteSchemaInitializationAdapter.java | 10 +- ...AnthropicClaudeAdapterIntegrationTest.java | 211 ++++++ .../ai/AnthropicClaudeHttpAdapterTest.java | 643 ++++++++++++++++++ .../adapter/out/ai/OpenAiHttpAdapterTest.java | 200 +++--- .../StartConfigurationValidatorTest.java | 459 ++----------- .../LegacyConfigurationMigratorTest.java | 351 ++++++++++ .../MultiProviderConfigurationTest.java | 281 ++++++++ ...ropertiesConfigurationPortAdapterTest.java | 232 ++++--- .../SqliteAttemptProviderPersistenceTest.java | 394 +++++++++++ ...rocessingAttemptRepositoryAdapterTest.java | 12 +- ...SqliteSchemaInitializationAdapterTest.java | 3 +- .../resources/missing-required.properties | 9 +- .../src/test/resources/no-api-key.properties | 9 +- .../test/resources/valid-config.properties | 9 +- .../config/provider/AiProviderFamily.java | 59 ++ .../provider/MultiProviderConfiguration.java | 43 ++ .../provider/ProviderConfiguration.java | 34 + .../config/startup/StartConfiguration.java | 17 +- .../port/out/ProcessingAttempt.java | 11 +- .../DocumentProcessingCoordinator.java | 29 +- .../DocumentProcessingCoordinatorTest.java | 72 +- .../TargetFilenameBuildingServiceTest.java | 1 + .../BatchRunProcessingUseCaseTest.java | 19 +- .../bootstrap/AiProviderSelector.java | 62 ++ .../umbenenner/bootstrap/BootstrapRunner.java | 110 ++- .../bootstrap/AiProviderSelectorTest.java | 134 ++++ .../BootstrapRunnerEdgeCasesTest.java | 55 +- .../bootstrap/BootstrapRunnerTest.java | 150 +++- .../bootstrap/BootstrapSmokeTest.java | 198 ++++++ .../bootstrap/ExecutableJarSmokeTestIT.java | 19 +- .../bootstrap/e2e/E2ETestContext.java | 37 +- .../e2e/ProviderIdentifierE2ETest.java | 397 +++++++++++ 44 files changed, 4912 insertions(+), 957 deletions(-) create mode 100644 docs/workpackages/V1.1 - Abschlussnachweis.md create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigrator.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationParser.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationValidator.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteAttemptProviderPersistenceTest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/AiProviderFamily.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/MultiProviderConfiguration.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java diff --git a/config/application-local.example.properties b/config/application-local.example.properties index 468ddf5..93f817d 100644 --- a/config/application-local.example.properties +++ b/config/application-local.example.properties @@ -1,8 +1,8 @@ -# PDF Umbenenner – Konfigurationsbeispiel für lokale Entwicklung +# PDF Umbenenner – Konfigurationsbeispiel fuer lokale Entwicklung # Kopiere diese Datei nach config/application.properties und passe die Werte an. # --------------------------------------------------------------------------- -# Pflichtparameter +# Pflichtparameter (allgemein) # --------------------------------------------------------------------------- # Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden. @@ -13,22 +13,12 @@ source.folder=./work/local/source # Wird automatisch angelegt, wenn er noch nicht existiert. target.folder=./work/local/target -# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie. -# Das übergeordnete Verzeichnis muss vorhanden sein. +# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie. +# Das uebergeordnete Verzeichnis muss vorhanden sein. sqlite.file=./work/local/pdf-umbenenner.db -# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions). -api.baseUrl=https://api.openai.com/v1 - -# Modellname des KI-Dienstes. -api.model=gpt-4o-mini - -# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein). -api.timeoutSeconds=30 - # Maximale Anzahl historisierter transienter Fehlversuche pro Dokument. -# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der -# Dokumentstatus auf FAILED_FINAL gesetzt. +# Muss eine ganze Zahl >= 1 sein. max.retries.transient=3 # Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als @@ -42,20 +32,11 @@ max.text.characters=5000 # in der Versuchshistorie. prompt.template.file=./config/prompts/template.txt -# --------------------------------------------------------------------------- -# API-Schlüssel -# --------------------------------------------------------------------------- -# Der API-Schlüssel kann wahlweise über diese Property oder über die -# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden. -# Die Umgebungsvariable hat Vorrang. -api.key=your-local-api-key-here - # --------------------------------------------------------------------------- # Optionale Parameter # --------------------------------------------------------------------------- -# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen). -# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis. +# Pfad zur Lock-Datei fuer den Startschutz (verhindert parallele Instanzen). runtime.lock.file=./work/local/pdf-umbenenner.lock # Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/. @@ -64,7 +45,42 @@ log.directory=./work/local/logs # Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO. log.level=INFO -# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben. -# Erlaubte Werte: true oder false. Standard ist false (geschützt). -# Nur für Diagnosezwecke auf true setzen. +# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben. +# Erlaubte Werte: true oder false. Standard ist false (geschuetzt). log.ai.sensitive=false + +# --------------------------------------------------------------------------- +# Aktiver KI-Provider +# --------------------------------------------------------------------------- +# Erlaubte Werte: openai-compatible, claude +ai.provider.active=openai-compatible + +# --------------------------------------------------------------------------- +# OpenAI-kompatibler Provider +# --------------------------------------------------------------------------- +# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions). +ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1 + +# Modellname des KI-Dienstes. +ai.provider.openai-compatible.model=gpt-4o-mini + +# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein). +ai.provider.openai-compatible.timeoutSeconds=30 + +# API-Schluessel. Die Umgebungsvariable OPENAI_COMPATIBLE_API_KEY hat Vorrang. +ai.provider.openai-compatible.apiKey=your-openai-api-key-here + +# --------------------------------------------------------------------------- +# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude) +# --------------------------------------------------------------------------- +# Basis-URL (optional; Standard: https://api.anthropic.com) +# ai.provider.claude.baseUrl=https://api.anthropic.com + +# Modellname (z. B. claude-3-5-sonnet-20241022) +# ai.provider.claude.model=claude-3-5-sonnet-20241022 + +# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein). +# ai.provider.claude.timeoutSeconds=60 + +# API-Schluessel. Die Umgebungsvariable ANTHROPIC_API_KEY hat Vorrang. +# ai.provider.claude.apiKey= diff --git a/config/application-test.example.properties b/config/application-test.example.properties index 4d7a7ab..4862e18 100644 --- a/config/application-test.example.properties +++ b/config/application-test.example.properties @@ -1,71 +1,46 @@ -# PDF Umbenenner – Konfigurationsbeispiel für Testläufe +# PDF Umbenenner – Konfigurationsbeispiel fuer Testlaeufe # Kopiere diese Datei nach config/application.properties und passe die Werte an. -# Diese Vorlage enthält kürzere Timeouts und niedrigere Limits für Testläufe. +# Diese Vorlage enthaelt kuerzere Timeouts und niedrigere Limits fuer Testlaeufe. # --------------------------------------------------------------------------- -# Pflichtparameter +# Pflichtparameter (allgemein) # --------------------------------------------------------------------------- -# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden. -# Der Ordner muss vorhanden und lesbar sein. source.folder=./work/test/source - -# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden. -# Wird automatisch angelegt, wenn er noch nicht existiert. target.folder=./work/test/target - -# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie. -# Das übergeordnete Verzeichnis muss vorhanden sein. sqlite.file=./work/test/pdf-umbenenner-test.db -# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions). -api.baseUrl=https://api.openai.com/v1 - -# Modellname des KI-Dienstes. -api.model=gpt-4o-mini - -# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein). -api.timeoutSeconds=10 - -# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument. -# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der -# Dokumentstatus auf FAILED_FINAL gesetzt. max.retries.transient=1 - -# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als -# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf). max.pages=5 - -# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird. max.text.characters=2000 - -# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator -# in der Versuchshistorie. prompt.template.file=./config/prompts/template.txt -# --------------------------------------------------------------------------- -# API-Schlüssel -# --------------------------------------------------------------------------- -# Der API-Schlüssel kann wahlweise über diese Property oder über die -# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden. -# Die Umgebungsvariable hat Vorrang. -api.key=test-api-key-placeholder - # --------------------------------------------------------------------------- # Optionale Parameter # --------------------------------------------------------------------------- -# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen). -# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis. runtime.lock.file=./work/test/pdf-umbenenner.lock - -# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/. log.directory=./work/test/logs - -# Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO. log.level=DEBUG - -# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben. -# Erlaubte Werte: true oder false. Standard ist false (geschützt). -# Nur für Diagnosezwecke auf true setzen. log.ai.sensitive=false + +# --------------------------------------------------------------------------- +# Aktiver KI-Provider +# --------------------------------------------------------------------------- +ai.provider.active=openai-compatible + +# --------------------------------------------------------------------------- +# OpenAI-kompatibler Provider +# --------------------------------------------------------------------------- +ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1 +ai.provider.openai-compatible.model=gpt-4o-mini +ai.provider.openai-compatible.timeoutSeconds=10 +ai.provider.openai-compatible.apiKey=test-api-key-placeholder + +# --------------------------------------------------------------------------- +# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude) +# --------------------------------------------------------------------------- +# ai.provider.claude.baseUrl=https://api.anthropic.com +# ai.provider.claude.model=claude-3-5-sonnet-20241022 +# ai.provider.claude.timeoutSeconds=60 +# ai.provider.claude.apiKey=your-anthropic-api-key-here diff --git a/docs/betrieb.md b/docs/betrieb.md index 4521e52..069d4e0 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -53,39 +53,98 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in: - `config/application-local.example.properties` - `config/application-test.example.properties` -### Pflichtparameter +### Pflichtparameter (allgemein) -| Parameter | Beschreibung | -|------------------------|--------------| -| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) | -| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) | -| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) | -| `api.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) | -| `api.model` | Modellname (z. B. `gpt-4o-mini`) | -| `api.timeoutSeconds` | HTTP-Timeout für KI-Anfragen in Sekunden (ganzzahlig, > 0) | -| `max.retries.transient`| Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) | -| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) | -| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) | -| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | +| Parameter | Beschreibung | +|-------------------------|--------------| +| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) | +| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) | +| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) | +| `ai.provider.active` | Aktiver KI-Provider: `openai-compatible` oder `claude` | +| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) | +| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) | +| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) | +| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) | + +### Provider-Parameter + +Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Provider wird nicht validiert. + +**OpenAI-kompatibler Provider** (`ai.provider.active=openai-compatible`): + +| Parameter | Beschreibung | +|-----------|--------------| +| `ai.provider.openai-compatible.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) | +| `ai.provider.openai-compatible.model` | Modellname (z. B. `gpt-4o-mini`) | +| `ai.provider.openai-compatible.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) | +| `ai.provider.openai-compatible.apiKey` | API-Schlüssel (Umgebungsvariable `OPENAI_COMPATIBLE_API_KEY` hat Vorrang) | + +**Anthropic Claude-Provider** (`ai.provider.active=claude`): + +| Parameter | Beschreibung | +|-----------|--------------| +| `ai.provider.claude.baseUrl` | Basis-URL (optional; Standard: `https://api.anthropic.com`) | +| `ai.provider.claude.model` | Modellname (z. B. `claude-3-5-sonnet-20241022`) | +| `ai.provider.claude.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) | +| `ai.provider.claude.apiKey` | API-Schlüssel (Umgebungsvariable `ANTHROPIC_API_KEY` hat Vorrang) | ### Optionale Parameter -| Parameter | Beschreibung | Standard | -|----------------------|--------------|---------| -| `api.key` | API-Schlüssel (alternativ: Umgebungsvariable `PDF_UMBENENNER_API_KEY`) | – | -| `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis | -| `log.directory` | Log-Verzeichnis | `./logs/` | -| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | -| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` | +| Parameter | Beschreibung | Standard | +|---------------------|--------------|---------| +| `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis | +| `log.directory` | Log-Verzeichnis | `./logs/` | +| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | +| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` | ### API-Schlüssel -Der API-Schlüssel kann auf zwei Wegen gesetzt werden: +Pro Provider-Familie existiert eine eigene Umgebungsvariable, die Vorrang vor dem Properties-Wert hat: -1. **Umgebungsvariable `PDF_UMBENENNER_API_KEY`** (empfohlen, hat Vorrang) -2. Property `api.key` in `config/application.properties` +| Provider | Umgebungsvariable | +|---|---| +| `openai-compatible` | `OPENAI_COMPATIBLE_API_KEY` | +| `claude` | `ANTHROPIC_API_KEY` | -Die Umgebungsvariable hat immer Vorrang über die Properties-Datei. +Schlüssel verschiedener Provider-Familien werden niemals vermischt. + +--- + +## Migration älterer Konfigurationsdateien + +Ältere Konfigurationsdateien, die noch die flachen Schlüssel `api.baseUrl`, `api.model`, +`api.timeoutSeconds` und `api.key` verwenden, werden beim ersten Start **automatisch** +in das aktuelle Schema überführt. + +### Was passiert + +1. Die Anwendung erkennt die veraltete Form anhand der flachen `api.*`-Schlüssel. +2. **Vor jeder Änderung** wird eine Sicherungskopie der Originaldatei angelegt: + - Standardfall: `config/application.properties.bak` + - Falls `.bak` bereits existiert: `config/application.properties.bak.1`, `.bak.2`, … + - Bestehende Sicherungen werden **niemals überschrieben**. +3. Die Datei wird in-place in das neue Schema überführt: + - `api.baseUrl` → `ai.provider.openai-compatible.baseUrl` + - `api.model` → `ai.provider.openai-compatible.model` + - `api.timeoutSeconds` → `ai.provider.openai-compatible.timeoutSeconds` + - `api.key` → `ai.provider.openai-compatible.apiKey` + - `ai.provider.active=openai-compatible` wird ergänzt. + - Alle übrigen Schlüssel bleiben unverändert. +4. Die migrierte Datei wird über eine temporäre Datei (`*.tmp`) und atomischen + Move/Rename geschrieben. Das Original wird niemals teilbeschrieben. +5. Die migrierte Datei wird sofort neu eingelesen und validiert. + +### Bei Migrationsfehler + +Schlägt die Validierung der migrierten Datei fehl, bricht die Anwendung mit Exit-Code `1` ab. +Die Sicherungskopie (`.bak`) bleibt in diesem Fall erhalten und enthält die unveränderte +Originaldatei. Die Konfiguration muss dann manuell korrigiert werden. + +### Betreiber-Hinweis + +Die Umgebungsvariable `PDF_UMBENENNER_API_KEY` des Vorgängerstands wird **nicht** automatisch +umbenannt. Falls dieser Wert bislang verwendet wurde, muss er auf `OPENAI_COMPATIBLE_API_KEY` +umgestellt werden. --- diff --git a/docs/workpackages/V1.1 - Abschlussnachweis.md b/docs/workpackages/V1.1 - Abschlussnachweis.md new file mode 100644 index 0000000..483f8d9 --- /dev/null +++ b/docs/workpackages/V1.1 - Abschlussnachweis.md @@ -0,0 +1,149 @@ +# V1.1 – Abschlussnachweis + +## Datum und betroffene Module + +**Datum:** 2026-04-09 + +**Betroffene Module:** + +| Modul | Art der Änderung | +|---|---| +| `pdf-umbenenner-application` | Neue Konfigurationstypen (`MultiProviderConfiguration`, `ProviderConfiguration`, `AiProviderFamily`) | +| `pdf-umbenenner-adapter-out` | Neuer Anthropic-Adapter (`AnthropicClaudeHttpAdapter`), neuer Parser (`MultiProviderConfigurationParser`), neuer Validator (`MultiProviderConfigurationValidator`), Migrator (`LegacyConfigurationMigrator`), Schema-Migration (`ai_provider`-Spalte), aktualisierter OpenAI-Adapter (`OpenAiHttpAdapter`), aktualisierter Properties-Adapter (`PropertiesConfigurationPortAdapter`) | +| `pdf-umbenenner-bootstrap` | Provider-Selektor (`AiProviderSelector`), aktualisierter `BootstrapRunner` (Migration, Provider-Auswahl, Logging) | +| `pdf-umbenenner-adapter-in-cli` | Keine fachliche Änderung | +| `pdf-umbenenner-domain` | Keine Änderung | +| `config/` | Beispiel-Properties-Dateien auf neues Schema aktualisiert | +| `docs/betrieb.md` | Abschnitte KI-Provider-Auswahl und Migration ergänzt | + +--- + +## Pflicht-Testfälle je Arbeitspaket + +### AP-001 – Konfigurations-Schema einführen + +| Testfall | Klasse | Status | +|---|---|---| +| `parsesNewSchemaWithOpenAiCompatibleActive` | `MultiProviderConfigurationTest` | grün | +| `parsesNewSchemaWithClaudeActive` | `MultiProviderConfigurationTest` | grün | +| `claudeBaseUrlDefaultsWhenMissing` | `MultiProviderConfigurationTest` | grün | +| `rejectsMissingActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `rejectsUnknownActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `rejectsMissingMandatoryFieldForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `acceptsMissingMandatoryFieldForInactiveProvider` | `MultiProviderConfigurationTest` | grün | +| `envVarOverridesPropertiesApiKeyForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| `envVarOnlyResolvesForActiveProvider` | `MultiProviderConfigurationTest` | grün | +| Bestehende Tests bleiben grün | `PropertiesConfigurationPortAdapterTest`, `StartConfigurationValidatorTest` | grün | + +### AP-002 – Legacy-Migration mit `.bak` + +| Testfall | Klasse | Status | +|---|---|---| +| `migratesLegacyFileWithAllFlatKeys` | `LegacyConfigurationMigratorTest` | grün | +| `createsBakBeforeOverwriting` | `LegacyConfigurationMigratorTest` | grün | +| `bakSuffixIsIncrementedIfBakExists` | `LegacyConfigurationMigratorTest` | grün | +| `noOpForAlreadyMigratedFile` | `LegacyConfigurationMigratorTest` | grün | +| `reloadAfterMigrationSucceeds` | `LegacyConfigurationMigratorTest` | grün | +| `migrationFailureKeepsBak` | `LegacyConfigurationMigratorTest` | grün | +| `legacyDetectionRequiresAtLeastOneFlatKey` | `LegacyConfigurationMigratorTest` | grün | +| `legacyValuesEndUpInOpenAiCompatibleNamespace` | `LegacyConfigurationMigratorTest` | grün | +| `unrelatedKeysSurviveUnchanged` | `LegacyConfigurationMigratorTest` | grün | +| `inPlaceWriteIsAtomic` | `LegacyConfigurationMigratorTest` | grün | + +### AP-003 – Bootstrap-Provider-Auswahl und Umstellung des bestehenden OpenAI-Adapters + +| Testfall | Klasse | Status | +|---|---|---| +| `bootstrapWiresOpenAiCompatibleAdapterWhenActive` | `AiProviderSelectorTest` | grün | +| `bootstrapFailsHardWhenActiveProviderUnknown` | `AiProviderSelectorTest` | grün | +| `bootstrapFailsHardWhenSelectedProviderHasNoImplementation` | `AiProviderSelectorTest` | grün | +| `openAiAdapterReadsValuesFromNewNamespace` | `OpenAiHttpAdapterTest` | grün | +| `openAiAdapterBehaviorIsUnchanged` | `OpenAiHttpAdapterTest` | grün | +| `activeProviderIsLoggedAtRunStart` | `BootstrapRunnerTest` | grün | +| `existingDocumentProcessingTestsRemainGreen` | `BatchRunEndToEndTest` | grün | +| `legacyFileEndToEndStillRuns` | `BootstrapRunnerTest` | grün | + +### AP-004 – Persistenz: Provider-Identifikator additiv + +| Testfall | Klasse | Status | +|---|---|---| +| `addsProviderColumnOnFreshDb` | `SqliteAttemptProviderPersistenceTest` | grün | +| `addsProviderColumnOnExistingDbWithoutColumn` | `SqliteAttemptProviderPersistenceTest` | grün | +| `migrationIsIdempotent` | `SqliteAttemptProviderPersistenceTest` | grün | +| `existingRowsKeepNullProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `newAttemptsWriteOpenAiCompatibleProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `newAttemptsWriteClaudeProvider` | `SqliteAttemptProviderPersistenceTest` | grün | +| `repositoryReadsProviderColumn` | `SqliteAttemptProviderPersistenceTest` | grün | +| `legacyDataReadingDoesNotFail` | `SqliteAttemptProviderPersistenceTest` | grün | +| `existingHistoryTestsRemainGreen` | `SqliteAttemptProviderPersistenceTest` | grün | + +### AP-005 – Nativer Anthropic-Adapter implementieren und verdrahten + +| Testfall | Klasse | Status | +|---|---|---| +| `claudeAdapterBuildsCorrectRequest` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterUsesEnvVarApiKey` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFallsBackToPropertiesApiKey` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFailsValidationWhenBothKeysMissing` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterParsesSingleTextBlock` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterConcatenatesMultipleTextBlocks` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterIgnoresNonTextBlocks` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterFailsOnEmptyTextContent` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp401AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp429AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsHttp500AsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsTimeoutAsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `claudeAdapterMapsUnparseableJsonAsTechnical` | `AnthropicClaudeHttpAdapterTest` | grün | +| `bootstrapSelectsClaudeWhenActive` | `AiProviderSelectorTest` | grün | +| `claudeProviderIdentifierLandsInAttemptHistory` | `AnthropicClaudeAdapterIntegrationTest` | grün | +| `existingOpenAiPathRemainsGreen` | alle `OpenAiHttpAdapterTest`-Tests | grün | + +### AP-006 – Regression, Smoke, Doku, Abschlussnachweis + +| Testfall | Klasse | Status | +|---|---|---| +| `smokeBootstrapWithOpenAiCompatibleActive` | `BootstrapSmokeTest` | grün | +| `smokeBootstrapWithClaudeActive` | `BootstrapSmokeTest` | grün | +| `e2eMigrationFromLegacyDemoConfig` | `ProviderIdentifierE2ETest` | grün | +| `regressionExistingOpenAiSuiteGreen` | `ProviderIdentifierE2ETest` | grün | +| `e2eClaudeRunWritesProviderIdentifierToHistory` | `ProviderIdentifierE2ETest` | grün | +| `e2eOpenAiRunWritesProviderIdentifierToHistory` | `ProviderIdentifierE2ETest` | grün | +| `legacyDataFromBeforeV11RemainsReadable` | `ProviderIdentifierE2ETest` | grün | + +--- + +## Belegte Eigenschaften + +| Eigenschaft | Nachweis | +|---|---| +| Zwei Provider-Familien unterstützt | `AiProviderSelectorTest`, `BootstrapSmokeTest` | +| Genau einer aktiv pro Lauf | `MultiProviderConfigurationTest`, `BootstrapSmokeTest` | +| Kein automatischer Fallback | keine Fallback-Logik in `AiProviderSelector` oder Application-Schicht | +| Fachlicher Vertrag (`NamingProposal`) unverändert | `AiResponseParser`, `AiNamingService` unverändert; beide Adapter liefern denselben Domain-Typ | +| Persistenz rückwärtsverträglich | `SqliteAttemptProviderPersistenceTest`, `legacyDataFromBeforeV11RemainsReadable` | +| Migration nachgewiesen | `LegacyConfigurationMigratorTest`, `e2eMigrationFromLegacyDemoConfig` | +| `.bak`-Sicherung nachgewiesen | `LegacyConfigurationMigratorTest.createsBakBeforeOverwriting`, `e2eMigrationFromLegacyDemoConfig` | +| Aktiver Provider wird geloggt | `BootstrapRunnerTest.activeProviderIsLoggedAtRunStart` | +| Keine Architekturbrüche | kein `Application`- oder `Domain`-Code kennt OpenAI- oder Claude-spezifische Typen | +| Keine neuen Bibliotheken | Anthropic-Adapter nutzt Java HTTP Client und `org.json` (beides bereits im Repo etabliert) | + +--- + +## Betreiberaufgabe + +Wer bisher die Umgebungsvariable `PDF_UMBENENNER_API_KEY` oder eine andere eigene Variable für den +OpenAI-kompatiblen API-Schlüssel eingesetzt hat, muss diese auf **`OPENAI_COMPATIBLE_API_KEY`** umstellen. +Die Anwendung akzeptiert nur diese kanonische Umgebungsvariable; ältere proprietäre Namen werden +nicht automatisch ausgewertet. + +--- + +## Build-Ergebnis + +Build-Kommando: + +``` +.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make +``` + +Build-Status: **ERFOLGREICH** — alle Tests grün, Mutationstests in allen Modulen ausgeführt. diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java new file mode 100644 index 0000000..26de81e --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java @@ -0,0 +1,394 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.ai; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Objects; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse; +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; + +/** + * Adapter implementing the native Anthropic Messages API for AI service invocation. + *

+ * This adapter: + *

+ * + *

Configuration

+ * + * + *

HTTP request structure

+ *

+ * The adapter sends a POST request to {@code {baseUrl}/v1/messages} with: + *

+ * + *

Response handling

+ * + * + *

Technical error classification

+ *

+ * All errors are mapped to {@link AiInvocationTechnicalFailure} and follow the existing + * transient error semantics. No new error categories are introduced: + *

+ * + *

Non-goals

+ * + */ +public class AnthropicClaudeHttpAdapter implements AiInvocationPort { + + private static final Logger LOG = LogManager.getLogger(AnthropicClaudeHttpAdapter.class); + + private static final String MESSAGES_ENDPOINT = "/v1/messages"; + private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version"; + private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01"; + private static final String API_KEY_HEADER = "x-api-key"; + private static final String CONTENT_TYPE = "application/json"; + private static final String DEFAULT_BASE_URL = "https://api.anthropic.com"; + + /** + * Fixed max_tokens value for the Anthropic request. + *

+ * This value is sufficient for the expected NamingProposal JSON response + * ({@code date}, {@code title}, {@code reasoning}) without requiring a separate + * configuration property. Anthropic's API requires this field to be present. + */ + private static final int MAX_TOKENS = 1024; + + private final HttpClient httpClient; + private final URI apiBaseUrl; + private final String apiModel; + private final String apiKey; + private final int apiTimeoutSeconds; + + // Test-only field to capture the last built JSON body for assertion + private volatile String lastBuiltJsonBody; + + /** + * Creates an adapter from the Claude provider configuration. + *

+ * If {@code config.baseUrl()} is absent or blank, the default Anthropic endpoint + * {@code https://api.anthropic.com} is used. The HTTP client is initialized with + * the configured timeout. + * + * @param config the provider configuration for the Claude family; must not be null + * @throws NullPointerException if config is null + * @throws IllegalArgumentException if the model is missing or blank + */ + public AnthropicClaudeHttpAdapter(ProviderConfiguration config) { + this(config, HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(config.timeoutSeconds())) + .build()); + } + + /** + * Creates an adapter with a custom HTTP client (primarily for testing). + *

+ * This constructor allows tests to inject a mock or configurable HTTP client + * while keeping configuration validation consistent with the production constructor. + *

+ * For testing only: This is package-private to remain internal to the adapter. + * + * @param config the provider configuration; must not be null + * @param httpClient the HTTP client to use; must not be null + * @throws NullPointerException if config or httpClient is null + * @throws IllegalArgumentException if the model is missing or blank + */ + AnthropicClaudeHttpAdapter(ProviderConfiguration config, HttpClient httpClient) { + Objects.requireNonNull(config, "config must not be null"); + Objects.requireNonNull(httpClient, "httpClient must not be null"); + if (config.model() == null || config.model().isBlank()) { + throw new IllegalArgumentException("API model must not be null or empty"); + } + + String baseUrlStr = (config.baseUrl() != null && !config.baseUrl().isBlank()) + ? config.baseUrl() + : DEFAULT_BASE_URL; + + this.apiBaseUrl = URI.create(baseUrlStr); + this.apiModel = config.model(); + this.apiKey = config.apiKey() != null ? config.apiKey() : ""; + this.apiTimeoutSeconds = config.timeoutSeconds(); + this.httpClient = httpClient; + + LOG.debug("AnthropicClaudeHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s", + apiBaseUrl, apiModel, apiTimeoutSeconds); + } + + /** + * Invokes the Anthropic Claude AI service with the given request. + *

+ * Constructs an Anthropic Messages API request from the request representation, + * executes it, extracts the text content from the response, and returns either + * a successful response or a classified technical failure. + * + * @param request the AI request with prompt and document text; must not be null + * @return an {@link AiInvocationResult} encoding either success (with extracted text) + * or a technical failure with classified reason + * @throws NullPointerException if request is null + */ + @Override + public AiInvocationResult invoke(AiRequestRepresentation request) { + Objects.requireNonNull(request, "request must not be null"); + + try { + HttpRequest httpRequest = buildRequest(request); + HttpResponse response = executeRequest(httpRequest); + + if (response.statusCode() == 200) { + return extractTextFromResponse(request, response.body()); + } else { + String reason = "HTTP_" + response.statusCode(); + String message = "Anthropic AI service returned status " + response.statusCode(); + LOG.warn("Claude AI invocation returned non-200 status: {}", response.statusCode()); + return new AiInvocationTechnicalFailure(request, reason, message); + } + } catch (java.net.http.HttpTimeoutException e) { + String message = "HTTP timeout: " + e.getClass().getSimpleName(); + LOG.warn("Claude AI invocation timeout: {}", message); + return new AiInvocationTechnicalFailure(request, "TIMEOUT", message); + } catch (java.net.ConnectException e) { + String message = "Failed to connect to endpoint: " + e.getMessage(); + LOG.warn("Claude AI invocation connection error: {}", message); + return new AiInvocationTechnicalFailure(request, "CONNECTION_ERROR", message); + } catch (java.net.UnknownHostException e) { + String message = "Endpoint hostname not resolvable: " + e.getMessage(); + LOG.warn("Claude AI invocation DNS error: {}", message); + return new AiInvocationTechnicalFailure(request, "DNS_ERROR", message); + } catch (java.io.IOException e) { + String message = "IO error during AI invocation: " + e.getMessage(); + LOG.warn("Claude AI invocation IO error: {}", message); + return new AiInvocationTechnicalFailure(request, "IO_ERROR", message); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + String message = "AI invocation interrupted: " + e.getMessage(); + LOG.warn("Claude AI invocation interrupted: {}", message); + return new AiInvocationTechnicalFailure(request, "INTERRUPTED", message); + } catch (Exception e) { + String message = "Unexpected error during AI invocation: " + e.getClass().getSimpleName() + + " - " + e.getMessage(); + LOG.error("Unexpected error in Claude AI invocation", e); + return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message); + } + } + + /** + * Builds an Anthropic Messages API request from the request representation. + *

+ * Constructs: + *

+ * + * @param request the request representation with prompt and document text + * @return an {@link HttpRequest} ready to send + */ + private HttpRequest buildRequest(AiRequestRepresentation request) { + URI endpoint = buildEndpointUri(); + String requestBody = buildJsonRequestBody(request); + // Capture for test inspection (test-only field) + this.lastBuiltJsonBody = requestBody; + + return HttpRequest.newBuilder(endpoint) + .header("content-type", CONTENT_TYPE) + .header(API_KEY_HEADER, apiKey) + .header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .timeout(Duration.ofSeconds(apiTimeoutSeconds)) + .build(); + } + + /** + * Composes the endpoint URI from the configured base URL. + *

+ * Resolves {@code {apiBaseUrl}/v1/messages}. + * + * @return the complete endpoint URI + */ + private URI buildEndpointUri() { + String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + MESSAGES_ENDPOINT; + return URI.create(apiBaseUrl.getScheme() + "://" + + apiBaseUrl.getHost() + + (apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") + + endpointPath); + } + + /** + * Builds the JSON request body for the Anthropic Messages API. + *

+ * The body contains: + *

+ *

+ * Package-private for testing: This method is accessible to tests + * in the same package to verify the actual JSON body structure and content. + * + * @param request the request with prompt and document text + * @return JSON string ready to send in HTTP body + */ + String buildJsonRequestBody(AiRequestRepresentation request) { + JSONObject body = new JSONObject(); + body.put("model", apiModel); + body.put("max_tokens", MAX_TOKENS); + + // Prompt content goes to the top-level system field (not a role=system message) + if (request.promptContent() != null && !request.promptContent().isBlank()) { + body.put("system", request.promptContent()); + } + + JSONObject userMessage = new JSONObject(); + userMessage.put("role", "user"); + userMessage.put("content", request.documentText()); + body.put("messages", new JSONArray().put(userMessage)); + + return body.toString(); + } + + /** + * Extracts the text content from a successful (HTTP 200) Anthropic response. + *

+ * Concatenates all {@code content} blocks with {@code type=="text"} in order. + * Blocks of other types (e.g., tool use) are ignored. + * If no {@code text} blocks are present, a technical failure is returned. + * + * @param request the original request (carried through to the result) + * @param responseBody the raw HTTP response body + * @return success with the concatenated text, or a technical failure + */ + private AiInvocationResult extractTextFromResponse(AiRequestRepresentation request, String responseBody) { + try { + JSONObject json = new JSONObject(responseBody); + JSONArray contentArray = json.getJSONArray("content"); + + StringBuilder textBuilder = new StringBuilder(); + for (int i = 0; i < contentArray.length(); i++) { + JSONObject block = contentArray.getJSONObject(i); + if ("text".equals(block.optString("type"))) { + textBuilder.append(block.getString("text")); + } + } + + String extractedText = textBuilder.toString(); + if (extractedText.isEmpty()) { + LOG.warn("Claude AI response contained no text-type content blocks"); + return new AiInvocationTechnicalFailure(request, "NO_TEXT_CONTENT", + "Anthropic response contained no text-type content blocks"); + } + + return new AiInvocationSuccess(request, new AiRawResponse(extractedText)); + } catch (JSONException e) { + LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage()); + return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON", + "Anthropic response body is not valid JSON: " + e.getMessage()); + } + } + + /** + * Package-private accessor for the last constructed JSON body. + *

+ * For testing only: Allows tests to verify the actual + * JSON body sent in HTTP requests without exposing the BodyPublisher internals. + * + * @return the last JSON body string constructed by {@link #buildRequest(AiRequestRepresentation)}, + * or null if no request has been built yet + */ + String getLastBuiltJsonBodyForTesting() { + return lastBuiltJsonBody; + } + + /** + * Executes the HTTP request and returns the response. + * + * @param httpRequest the HTTP request to execute + * @return the HTTP response with status code and body + * @throws java.net.http.HttpTimeoutException if the request times out + * @throws java.net.ConnectException if connection fails + * @throws java.io.IOException on other IO errors + * @throws InterruptedException if the request is interrupted + */ + private HttpResponse executeRequest(HttpRequest httpRequest) + throws java.io.IOException, InterruptedException { + return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java index 88669b4..7f27c5b 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java @@ -11,7 +11,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.json.JSONObject; -import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; @@ -26,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; *

* *

Legacy-state migration

@@ -150,6 +153,9 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti /** * Columns to add idempotently to {@code processing_attempt}. * Each entry is {@code [column_name, column_type]}. + *

+ * {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the + * correct sentinel for attempts recorded before provider tracking was introduced. */ private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = { {"model_name", "TEXT"}, @@ -162,6 +168,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti {"date_source", "TEXT"}, {"validated_title", "TEXT"}, {"final_target_file_name", "TEXT"}, + {"ai_provider", "TEXT"}, }; // ------------------------------------------------------------------------- @@ -229,7 +236,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti *

  • Create {@code document_record} table (if not exists).
  • *
  • Create {@code processing_attempt} table (if not exists).
  • *
  • Create all indexes (if not exist).
  • - *
  • Add AI-traceability columns to {@code processing_attempt} (idempotent evolution).
  • + *
  • Add AI-traceability and provider-identifier columns to {@code processing_attempt} + * (idempotent evolution).
  • *
  • Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).
  • * *

    diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java new file mode 100644 index 0000000..d9695d8 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeAdapterIntegrationTest.java @@ -0,0 +1,211 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.junit.jupiter.MockitoExtension; + +import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter; +import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity; +import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; +import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; +import de.gecheckt.pdf.umbenenner.application.service.AiNamingService; +import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator; +import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; +import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; +import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; +import de.gecheckt.pdf.umbenenner.domain.model.RunId; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; + +/** + * Integration test verifying that the Anthropic Claude adapter integrates correctly + * with the full batch processing pipeline and that the provider identifier + * {@code "claude"} is persisted in the processing attempt history. + *

    + * Uses a mocked HTTP client to simulate the Anthropic API without real network calls. + * All other adapters (SQLite, filesystem, PDF extraction, fingerprinting) are real + * production implementations. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AnthropicClaudeAdapter integration") +class AnthropicClaudeAdapterIntegrationTest { + + /** + * Pflicht-Testfall 15: claudeProviderIdentifierLandsInAttemptHistory + *

    + * Verifies the end-to-end integration: the Claude adapter with a mocked HTTP layer + * is wired into the batch pipeline, and after a successful run, the processing attempt + * record contains {@code ai_provider='claude'}. + */ + @Test + @DisplayName("claudeProviderIdentifierLandsInAttemptHistory: ai_provider=claude in attempt history after successful run") + @SuppressWarnings("unchecked") + void claudeProviderIdentifierLandsInAttemptHistory(@TempDir Path tempDir) throws Exception { + // --- Infrastructure setup --- + Path sourceFolder = Files.createDirectories(tempDir.resolve("source")); + Path targetFolder = Files.createDirectories(tempDir.resolve("target")); + Path promptFile = tempDir.resolve("prompt.txt"); + Files.writeString(promptFile, "Analysiere das Dokument und liefere JSON."); + + String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("test.db") + .toAbsolutePath().toString().replace('\\', '/'); + new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema(); + + // --- Create a searchable PDF in the source folder --- + Path pdfPath = sourceFolder.resolve("testdokument.pdf"); + createSearchablePdf(pdfPath, "Testinhalt Rechnung Datum 15.01.2024 Betrag 99 EUR"); + + // --- Compute fingerprint for later verification --- + Sha256FingerprintAdapter fingerprintAdapter = new Sha256FingerprintAdapter(); + SourceDocumentCandidate candidate = new SourceDocumentCandidate( + pdfPath.getFileName().toString(), 0L, + new SourceDocumentLocator(pdfPath.toAbsolutePath().toString())); + DocumentFingerprint fingerprint = switch (fingerprintAdapter.computeFingerprint(candidate)) { + case FingerprintSuccess s -> s.fingerprint(); + default -> throw new IllegalStateException("Fingerprint computation failed"); + }; + + // --- Mock the HTTP client for the Claude adapter --- + HttpClient mockHttpClient = mock(HttpClient.class); + // Build a valid Anthropic response with the NamingProposal JSON as text content + String namingProposalJson = + "{\\\"date\\\":\\\"2024-01-15\\\",\\\"title\\\":\\\"Testrechnung\\\"," + + "\\\"reasoning\\\":\\\"Rechnung vom 15.01.2024\\\"}"; + String anthropicResponseBody = "{" + + "\"id\":\"msg_integration_test\"," + + "\"type\":\"message\"," + + "\"role\":\"assistant\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"" + namingProposalJson + "\"}]," + + "\"stop_reason\":\"end_turn\"" + + "}"; + + HttpResponse mockHttpResponse = mock(HttpResponse.class); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(anthropicResponseBody); + when(mockHttpClient.send(any(HttpRequest.class), any())) + .thenReturn((HttpResponse) mockHttpResponse); + + // --- Create the Claude adapter with the mocked HTTP client --- + ProviderConfiguration claudeConfig = new ProviderConfiguration( + "claude-3-5-sonnet-20241022", 60, "https://api.anthropic.com", "sk-ant-test"); + AnthropicClaudeHttpAdapter claudeAdapter = + new AnthropicClaudeHttpAdapter(claudeConfig, mockHttpClient); + + // --- Wire the full pipeline with provider identifier "claude" --- + SqliteDocumentRecordRepositoryAdapter documentRepo = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + SqliteProcessingAttemptRepositoryAdapter attemptRepo = + new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + SqliteUnitOfWorkAdapter unitOfWork = new SqliteUnitOfWorkAdapter(jdbcUrl); + + ProcessingLogger noOpLogger = new NoOpProcessingLogger(); + DocumentProcessingCoordinator coordinator = new DocumentProcessingCoordinator( + documentRepo, attemptRepo, unitOfWork, + new FilesystemTargetFolderAdapter(targetFolder), + new FilesystemTargetFileCopyAdapter(targetFolder), + noOpLogger, + 3, + "claude"); // provider identifier for Claude + + AiNamingService aiNamingService = new AiNamingService( + claudeAdapter, + new FilesystemPromptPortAdapter(promptFile), + new AiResponseValidator(new SystemClockAdapter()), + "claude-3-5-sonnet-20241022", + 10_000); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + new RuntimeConfiguration(50, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT), + new FilesystemRunLockPortAdapter(tempDir.resolve("run.lock")), + new SourceDocumentCandidatesPortAdapter(sourceFolder), + new PdfTextExtractionPortAdapter(), + fingerprintAdapter, + coordinator, + aiNamingService, + noOpLogger); + + // --- Run the batch --- + BatchRunContext context = new BatchRunContext( + new RunId(UUID.randomUUID().toString()), Instant.now()); + useCase.execute(context); + + // --- Verify: ai_provider='claude' is stored in the attempt history --- + List attempts = attemptRepo.findAllByFingerprint(fingerprint); + assertThat(attempts) + .as("At least one attempt must be recorded") + .isNotEmpty(); + assertThat(attempts.get(0).aiProvider()) + .as("Provider identifier must be 'claude' in the attempt history") + .isEqualTo("claude"); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Creates a single-page searchable PDF with embedded text using PDFBox. + */ + private static void createSearchablePdf(Path pdfPath, String text) throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 700); + cs.showText(text); + cs.endText(); + } + doc.save(pdfPath.toFile()); + } + } + + /** + * No-op implementation of {@link ProcessingLogger} for use in integration tests + * where log output is not relevant to the assertion. + */ + private static class NoOpProcessingLogger implements ProcessingLogger { + @Override public void info(String message, Object... args) {} + @Override public void debug(String message, Object... args) {} + @Override public void warn(String message, Object... args) {} + @Override public void error(String message, Object... args) {} + @Override public void debugSensitiveAiContent(String message, Object... args) {} + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java new file mode 100644 index 0000000..308638b --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java @@ -0,0 +1,643 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.adapter.out.configuration.MultiProviderConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; +import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; + +/** + * Unit tests for {@link AnthropicClaudeHttpAdapter}. + *

    + * Tests inject a mock {@link HttpClient} via the package-private constructor + * to exercise the adapter path without requiring network access. + * Configuration is supplied via {@link ProviderConfiguration}. + *

    + * Covered scenarios: + *

    + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AnthropicClaudeHttpAdapter") +class AnthropicClaudeHttpAdapterTest { + + private static final String API_BASE_URL = "https://api.anthropic.com"; + private static final String API_MODEL = "claude-3-5-sonnet-20241022"; + private static final String API_KEY = "sk-ant-test-key-12345"; + private static final int TIMEOUT_SECONDS = 60; + + @Mock + private HttpClient httpClient; + + private ProviderConfiguration testConfiguration; + private AnthropicClaudeHttpAdapter adapter; + + @BeforeEach + void setUp() { + testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY); + adapter = new AnthropicClaudeHttpAdapter(testConfiguration, httpClient); + } + + // ========================================================================= + // Pflicht-Testfall 1: claudeAdapterBuildsCorrectRequest + // ========================================================================= + + /** + * Verifies that the adapter constructs the correct HTTP request: + * URL with {@code /v1/messages} path, method POST, all three required headers + * ({@code x-api-key}, {@code anthropic-version}, {@code content-type}), and + * a body with {@code model}, {@code max_tokens > 0}, and {@code messages} containing + * exactly one user message with the document text. + */ + @Test + @DisplayName("claudeAdapterBuildsCorrectRequest: correct URL, method, headers, and body") + void claudeAdapterBuildsCorrectRequest() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, buildAnthropicSuccessResponse( + "{\"date\":\"2024-01-15\",\"title\":\"Testititel\",\"reasoning\":\"Test\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiRequestRepresentation request = createTestRequest("System-Prompt", "Dokumenttext"); + adapter.invoke(request); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + HttpRequest capturedRequest = requestCaptor.getValue(); + + // URL must point to /v1/messages + assertThat(capturedRequest.uri().toString()) + .as("URL must be based on configured baseUrl") + .startsWith(API_BASE_URL) + .endsWith("/v1/messages"); + + // Method must be POST + assertThat(capturedRequest.method()).isEqualTo("POST"); + + // All three required headers must be present + assertThat(capturedRequest.headers().firstValue("x-api-key")) + .as("x-api-key header must be present") + .isPresent(); + assertThat(capturedRequest.headers().firstValue("anthropic-version")) + .as("anthropic-version header must be present") + .isPresent() + .hasValue("2023-06-01"); + assertThat(capturedRequest.headers().firstValue("content-type")) + .as("content-type header must be present") + .isPresent(); + + // Body must contain model, max_tokens > 0, and messages with one user message + String sentBody = adapter.getLastBuiltJsonBodyForTesting(); + JSONObject body = new JSONObject(sentBody); + assertThat(body.getString("model")) + .as("model must match configuration") + .isEqualTo(API_MODEL); + assertThat(body.getInt("max_tokens")) + .as("max_tokens must be positive") + .isGreaterThan(0); + assertThat(body.getJSONArray("messages").length()) + .as("messages must contain exactly one entry") + .isEqualTo(1); + assertThat(body.getJSONArray("messages").getJSONObject(0).getString("role")) + .as("the single message must be a user message") + .isEqualTo("user"); + assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content")) + .as("user message content must be the document text") + .isEqualTo("Dokumenttext"); + } + + // ========================================================================= + // Pflicht-Testfall 2: claudeAdapterUsesEnvVarApiKey + // ========================================================================= + + /** + * Verifies that when the {@code ANTHROPIC_API_KEY} environment variable is the source + * of the resolved API key (represented in ProviderConfiguration after env-var precedence + * was applied by the configuration layer), the adapter uses that key in the + * {@code x-api-key} header. + */ + @Test + @DisplayName("claudeAdapterUsesEnvVarApiKey: env var value reaches x-api-key header") + void claudeAdapterUsesEnvVarApiKey() throws Exception { + String envVarValue = "sk-ant-from-env-variable"; + // Env var takes precedence: the configuration layer resolves this into apiKey + ProviderConfiguration configWithEnvKey = new ProviderConfiguration( + API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, envVarValue); + AnthropicClaudeHttpAdapter adapterWithEnvKey = + new AnthropicClaudeHttpAdapter(configWithEnvKey, httpClient); + + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + adapterWithEnvKey.invoke(createTestRequest("prompt", "doc")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + assertThat(requestCaptor.getValue().headers().firstValue("x-api-key")) + .as("x-api-key header must contain the env var value") + .hasValue(envVarValue); + } + + // ========================================================================= + // Pflicht-Testfall 3: claudeAdapterFallsBackToPropertiesApiKey + // ========================================================================= + + /** + * Verifies that when no environment variable is set, the API key from the + * properties configuration is used in the {@code x-api-key} header. + */ + @Test + @DisplayName("claudeAdapterFallsBackToPropertiesApiKey: properties key reaches x-api-key header") + void claudeAdapterFallsBackToPropertiesApiKey() throws Exception { + String propertiesKey = "sk-ant-from-properties"; + ProviderConfiguration configWithPropertiesKey = new ProviderConfiguration( + API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, propertiesKey); + AnthropicClaudeHttpAdapter adapterWithPropertiesKey = + new AnthropicClaudeHttpAdapter(configWithPropertiesKey, httpClient); + + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + adapterWithPropertiesKey.invoke(createTestRequest("prompt", "doc")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + assertThat(requestCaptor.getValue().headers().firstValue("x-api-key")) + .as("x-api-key header must contain the properties value") + .hasValue(propertiesKey); + } + + // ========================================================================= + // Pflicht-Testfall 4: claudeAdapterFailsValidationWhenBothKeysMissing + // ========================================================================= + + /** + * Verifies that when both the environment variable and the properties API key for the + * Claude provider are empty, the {@link MultiProviderConfigurationValidator} rejects the + * configuration with an {@link InvalidStartConfigurationException}. + *

    + * This confirms that the adapter is protected by startup validation (from AP-001) + * and will never be constructed with a truly missing API key in production. + */ + @Test + @DisplayName("claudeAdapterFailsValidationWhenBothKeysMissing: validator rejects empty API key for Claude") + void claudeAdapterFailsValidationWhenBothKeysMissing() { + // Simulate both env var and properties key being absent (empty resolved key) + ProviderConfiguration claudeConfigWithoutKey = new ProviderConfiguration( + API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""); + ProviderConfiguration inactiveOpenAiConfig = new ProviderConfiguration( + "unused-model", 0, null, null); + MultiProviderConfiguration config = new MultiProviderConfiguration( + AiProviderFamily.CLAUDE, inactiveOpenAiConfig, claudeConfigWithoutKey); + + MultiProviderConfigurationValidator validator = new MultiProviderConfigurationValidator(); + + assertThatThrownBy(() -> validator.validate(config)) + .as("Validator must reject Claude configuration with empty API key") + .isInstanceOf(InvalidStartConfigurationException.class); + } + + // ========================================================================= + // Pflicht-Testfall 5: claudeAdapterParsesSingleTextBlock + // ========================================================================= + + /** + * Verifies that a response with a single text block is correctly extracted. + */ + @Test + @DisplayName("claudeAdapterParsesSingleTextBlock: single text block becomes raw response") + void claudeAdapterParsesSingleTextBlock() throws Exception { + String blockText = "{\"date\":\"2024-01-15\",\"title\":\"Rechnung\",\"reasoning\":\"Test\"}"; + String responseBody = buildAnthropicSuccessResponse(blockText); + HttpResponse httpResponse = mockHttpResponse(200, responseBody); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationSuccess.class); + AiInvocationSuccess success = (AiInvocationSuccess) result; + assertThat(success.rawResponse().content()) + .as("Raw response must equal the text block content") + .isEqualTo(blockText); + } + + // ========================================================================= + // Pflicht-Testfall 6: claudeAdapterConcatenatesMultipleTextBlocks + // ========================================================================= + + /** + * Verifies that multiple text blocks are concatenated in order. + */ + @Test + @DisplayName("claudeAdapterConcatenatesMultipleTextBlocks: text blocks are concatenated in order") + void claudeAdapterConcatenatesMultipleTextBlocks() throws Exception { + String part1 = "Erster Teil der Antwort. "; + String part2 = "Zweiter Teil der Antwort."; + + // Build the response using JSONObject to ensure correct escaping + JSONObject block1 = new JSONObject(); + block1.put("type", "text"); + block1.put("text", part1); + JSONObject block2 = new JSONObject(); + block2.put("type", "text"); + block2.put("text", part2); + JSONObject responseJson = new JSONObject(); + responseJson.put("id", "msg_test"); + responseJson.put("type", "message"); + responseJson.put("role", "assistant"); + responseJson.put("content", new JSONArray().put(block1).put(block2)); + responseJson.put("stop_reason", "end_turn"); + + HttpResponse httpResponse = mockHttpResponse(200, responseJson.toString()); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationSuccess.class); + assertThat(((AiInvocationSuccess) result).rawResponse().content()) + .as("Multiple text blocks must be concatenated in order") + .isEqualTo(part1 + part2); + } + + // ========================================================================= + // Pflicht-Testfall 7: claudeAdapterIgnoresNonTextBlocks + // ========================================================================= + + /** + * Verifies that non-text content blocks (e.g., tool_use) are ignored and only + * the text blocks contribute to the raw response. + */ + @Test + @DisplayName("claudeAdapterIgnoresNonTextBlocks: only text-type blocks contribute to response") + void claudeAdapterIgnoresNonTextBlocks() throws Exception { + String textContent = "Nur dieser Text zaehlt als Antwort."; + + // Build response with a tool_use block before and a tool_result-like block after the text block + JSONObject toolUseBlock = new JSONObject(); + toolUseBlock.put("type", "tool_use"); + toolUseBlock.put("id", "tool_1"); + toolUseBlock.put("name", "get_weather"); + toolUseBlock.put("input", new JSONObject()); + + JSONObject textBlock = new JSONObject(); + textBlock.put("type", "text"); + textBlock.put("text", textContent); + + JSONObject ignoredBlock = new JSONObject(); + ignoredBlock.put("type", "tool_result"); + ignoredBlock.put("content", "irrelevant"); + + JSONObject responseJson = new JSONObject(); + responseJson.put("id", "msg_test"); + responseJson.put("type", "message"); + responseJson.put("role", "assistant"); + responseJson.put("content", new JSONArray().put(toolUseBlock).put(textBlock).put(ignoredBlock)); + responseJson.put("stop_reason", "end_turn"); + + HttpResponse httpResponse = mockHttpResponse(200, responseJson.toString()); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationSuccess.class); + assertThat(((AiInvocationSuccess) result).rawResponse().content()) + .as("Only text-type blocks must contribute to the raw response") + .isEqualTo(textContent); + } + + // ========================================================================= + // Pflicht-Testfall 8: claudeAdapterFailsOnEmptyTextContent + // ========================================================================= + + /** + * Verifies that a response with no text-type content blocks results in a + * technical failure. + */ + @Test + @DisplayName("claudeAdapterFailsOnEmptyTextContent: no text blocks yields technical failure") + void claudeAdapterFailsOnEmptyTextContent() throws Exception { + String noTextBlockResponse = "{" + + "\"id\":\"msg_test\"," + + "\"type\":\"message\"," + + "\"role\":\"assistant\"," + + "\"content\":[" + + "{\"type\":\"tool_use\",\"id\":\"tool_1\",\"name\":\"unused\",\"input\":{}}" + + "]," + + "\"stop_reason\":\"tool_use\"" + + "}"; + + HttpResponse httpResponse = mockHttpResponse(200, noTextBlockResponse); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()) + .isEqualTo("NO_TEXT_CONTENT"); + } + + // ========================================================================= + // Pflicht-Testfall 9: claudeAdapterMapsHttp401AsTechnical + // ========================================================================= + + /** + * Verifies that HTTP 401 (Unauthorized) is classified as a technical failure. + */ + @Test + @DisplayName("claudeAdapterMapsHttp401AsTechnical: HTTP 401 yields technical failure") + void claudeAdapterMapsHttp401AsTechnical() throws Exception { + HttpResponse httpResponse = mockHttpResponse(401, null); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_401"); + } + + // ========================================================================= + // Pflicht-Testfall 10: claudeAdapterMapsHttp429AsTechnical + // ========================================================================= + + /** + * Verifies that HTTP 429 (Rate Limit Exceeded) is classified as a technical failure. + */ + @Test + @DisplayName("claudeAdapterMapsHttp429AsTechnical: HTTP 429 yields technical failure") + void claudeAdapterMapsHttp429AsTechnical() throws Exception { + HttpResponse httpResponse = mockHttpResponse(429, null); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429"); + } + + // ========================================================================= + // Pflicht-Testfall 11: claudeAdapterMapsHttp500AsTechnical + // ========================================================================= + + /** + * Verifies that HTTP 500 (Internal Server Error) is classified as a technical failure. + */ + @Test + @DisplayName("claudeAdapterMapsHttp500AsTechnical: HTTP 500 yields technical failure") + void claudeAdapterMapsHttp500AsTechnical() throws Exception { + HttpResponse httpResponse = mockHttpResponse(500, null); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_500"); + } + + // ========================================================================= + // Pflicht-Testfall 12: claudeAdapterMapsTimeoutAsTechnical + // ========================================================================= + + /** + * Verifies that a simulated HTTP timeout results in a technical failure with + * reason {@code TIMEOUT}. + */ + @Test + @DisplayName("claudeAdapterMapsTimeoutAsTechnical: timeout yields TIMEOUT technical failure") + void claudeAdapterMapsTimeoutAsTechnical() throws Exception { + when(httpClient.send(any(HttpRequest.class), any())) + .thenThrow(new HttpTimeoutException("Connection timed out")); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT"); + } + + // ========================================================================= + // Pflicht-Testfall 13: claudeAdapterMapsUnparseableJsonAsTechnical + // ========================================================================= + + /** + * Verifies that a non-JSON response body (e.g., an HTML error page or plain text) + * returned with HTTP 200 results in a technical failure. + */ + @Test + @DisplayName("claudeAdapterMapsUnparseableJsonAsTechnical: non-JSON body yields technical failure") + void claudeAdapterMapsUnparseableJsonAsTechnical() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, + "Service unavailable"); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("UNPARSEABLE_JSON"); + } + + // ========================================================================= + // Additional behavioral tests + // ========================================================================= + + @Test + @DisplayName("should use configured model in request body") + void testConfiguredModelIsUsedInRequestBody() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + adapter.invoke(createTestRequest("prompt", "doc")); + + String sentBody = adapter.getLastBuiltJsonBodyForTesting(); + assertThat(new JSONObject(sentBody).getString("model")).isEqualTo(API_MODEL); + } + + @Test + @DisplayName("should use configured timeout in request") + void testConfiguredTimeoutIsUsedInRequest() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + adapter.invoke(createTestRequest("prompt", "doc")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + assertThat(requestCaptor.getValue().timeout()) + .isPresent() + .get() + .isEqualTo(Duration.ofSeconds(TIMEOUT_SECONDS)); + } + + @Test + @DisplayName("should place prompt content in system field and document text in user message") + void testPromptContentGoesToSystemFieldDocumentTextToUserMessage() throws Exception { + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + String promptContent = "Du bist ein Assistent zur Dokumentenbenennung."; + String documentText = "Rechnungstext des Dokuments."; + adapter.invoke(createTestRequest(promptContent, documentText)); + + String sentBody = adapter.getLastBuiltJsonBodyForTesting(); + JSONObject body = new JSONObject(sentBody); + + assertThat(body.getString("system")) + .as("Prompt content must be placed in the top-level system field") + .isEqualTo(promptContent); + assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content")) + .as("Document text must be placed in the user message content") + .isEqualTo(documentText); + } + + @Test + @DisplayName("should map CONNECTION_ERROR when ConnectException is thrown") + void testConnectionExceptionIsMappedToConnectionError() throws Exception { + when(httpClient.send(any(HttpRequest.class), any())) + .thenThrow(new ConnectException("Connection refused")); + + AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("CONNECTION_ERROR"); + } + + @Test + @DisplayName("should map DNS_ERROR when UnknownHostException is thrown") + void testUnknownHostExceptionIsMappedToDnsError() throws Exception { + when(httpClient.send(any(HttpRequest.class), any())) + .thenThrow(new UnknownHostException("api.anthropic.com")); + + AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); + + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("DNS_ERROR"); + } + + @Test + @DisplayName("should throw NullPointerException when request is null") + void testNullRequestThrowsException() { + assertThatThrownBy(() -> adapter.invoke(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("request must not be null"); + } + + @Test + @DisplayName("should throw NullPointerException when configuration is null") + void testNullConfigurationThrowsException() { + assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(null, httpClient)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("config must not be null"); + } + + @Test + @DisplayName("should throw IllegalArgumentException when API model is blank") + void testBlankApiModelThrowsException() { + ProviderConfiguration invalidConfig = new ProviderConfiguration( + " ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY); + + assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(invalidConfig, httpClient)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("API model must not be null or empty"); + } + + @Test + @DisplayName("should use default base URL when baseUrl is null") + void testDefaultBaseUrlUsedWhenNull() throws Exception { + ProviderConfiguration configWithoutBaseUrl = new ProviderConfiguration( + API_MODEL, TIMEOUT_SECONDS, null, API_KEY); + AnthropicClaudeHttpAdapter adapterWithDefault = + new AnthropicClaudeHttpAdapter(configWithoutBaseUrl, httpClient); + + HttpResponse httpResponse = mockHttpResponse(200, + buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}")); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + adapterWithDefault.invoke(createTestRequest("p", "d")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + assertThat(requestCaptor.getValue().uri().toString()) + .as("Default base URL https://api.anthropic.com must be used when baseUrl is null") + .startsWith("https://api.anthropic.com"); + } + + // ========================================================================= + // Helper methods + // ========================================================================= + + /** + * Builds a minimal valid Anthropic Messages API response body with a single text block. + */ + private static String buildAnthropicSuccessResponse(String textContent) { + // Escape the textContent for embedding in JSON string + String escaped = textContent + .replace("\\", "\\\\") + .replace("\"", "\\\""); + return "{" + + "\"id\":\"msg_test\"," + + "\"type\":\"message\"," + + "\"role\":\"assistant\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"" + escaped + "\"}]," + + "\"stop_reason\":\"end_turn\"" + + "}"; + } + + @SuppressWarnings("unchecked") + private HttpResponse mockHttpResponse(int statusCode, String body) { + HttpResponse response = (HttpResponse) mock(HttpResponse.class); + when(response.statusCode()).thenReturn(statusCode); + if (body != null) { + when(response.body()).thenReturn(body); + } + return response; + } + + private AiRequestRepresentation createTestRequest(String promptContent, String documentText) { + return new AiRequestRepresentation( + new PromptIdentifier("test-v1"), + promptContent, + documentText, + documentText.length() + ); + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java index 18e217a..47f5b1c 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java @@ -5,13 +5,11 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import java.net.ConnectException; -import java.net.URI; import java.net.UnknownHostException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; -import java.nio.file.Paths; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; @@ -25,11 +23,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.json.JSONArray; import org.json.JSONObject; -import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure; -import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse; import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; @@ -39,6 +36,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; * Test strategy: * Tests inject a mock {@link HttpClient} via the package-private constructor * to exercise the real HTTP adapter path without requiring network access. + * Configuration is supplied via {@link ProviderConfiguration}. *

    * Coverage goals: *

      @@ -56,6 +54,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; *
    • Effective API key is actually used in the Authorization header
    • *
    • Full document text is sent (not truncated)
    • *
    • Null request raises NullPointerException
    • + *
    • Adapter reads all values from ProviderConfiguration (AP-003)
    • + *
    • Behavioral contracts are unchanged after constructor change (AP-003)
    • *
    */ @ExtendWith(MockitoExtension.class) @@ -70,28 +70,12 @@ class OpenAiHttpAdapterTest { @Mock private HttpClient httpClient; - private StartConfiguration testConfiguration; + private ProviderConfiguration testConfiguration; private OpenAiHttpAdapter adapter; @BeforeEach void setUp() { - testConfiguration = new StartConfiguration( - Paths.get("/source"), - Paths.get("/target"), - Paths.get("/db.sqlite"), - URI.create(API_BASE_URL), - API_MODEL, - TIMEOUT_SECONDS, - 5, - 100, - 5000, - Paths.get("/prompt.txt"), - Paths.get("/lock"), - Paths.get("/logs"), - "INFO", - API_KEY, - false - ); + testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY); // Use the package-private constructor with injected mock HttpClient adapter = new OpenAiHttpAdapter(testConfiguration, httpClient); } @@ -242,7 +226,6 @@ class OpenAiHttpAdapterTest { verify(httpClient).send(requestCaptor.capture(), any()); HttpRequest capturedRequest = requestCaptor.getValue(); - // Verify the timeout was actually configured on the request assertThat(capturedRequest.timeout()) .as("HttpRequest timeout should be present") .isPresent() @@ -437,23 +420,8 @@ class OpenAiHttpAdapterTest { @Test @DisplayName("should throw IllegalArgumentException when API base URL is null") void testNullApiBaseUrlThrowsException() { - StartConfiguration invalidConfig = new StartConfiguration( - Paths.get("/source"), - Paths.get("/target"), - Paths.get("/db.sqlite"), - null, // Invalid: null base URL - API_MODEL, - TIMEOUT_SECONDS, - 5, - 100, - 5000, - Paths.get("/prompt.txt"), - Paths.get("/lock"), - Paths.get("/logs"), - "INFO", - API_KEY, - false - ); + ProviderConfiguration invalidConfig = new ProviderConfiguration( + API_MODEL, TIMEOUT_SECONDS, null, API_KEY); assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) .isInstanceOf(IllegalArgumentException.class) @@ -463,23 +431,8 @@ class OpenAiHttpAdapterTest { @Test @DisplayName("should throw IllegalArgumentException when API model is null") void testNullApiModelThrowsException() { - StartConfiguration invalidConfig = new StartConfiguration( - Paths.get("/source"), - Paths.get("/target"), - Paths.get("/db.sqlite"), - URI.create(API_BASE_URL), - null, // Invalid: null model - TIMEOUT_SECONDS, - 5, - 100, - 5000, - Paths.get("/prompt.txt"), - Paths.get("/lock"), - Paths.get("/logs"), - "INFO", - API_KEY, - false - ); + ProviderConfiguration invalidConfig = new ProviderConfiguration( + null, TIMEOUT_SECONDS, API_BASE_URL, API_KEY); assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) .isInstanceOf(IllegalArgumentException.class) @@ -489,23 +442,8 @@ class OpenAiHttpAdapterTest { @Test @DisplayName("should throw IllegalArgumentException when API model is blank") void testBlankApiModelThrowsException() { - StartConfiguration invalidConfig = new StartConfiguration( - Paths.get("/source"), - Paths.get("/target"), - Paths.get("/db.sqlite"), - URI.create(API_BASE_URL), - " ", // Invalid: blank model - TIMEOUT_SECONDS, - 5, - 100, - 5000, - Paths.get("/prompt.txt"), - Paths.get("/lock"), - Paths.get("/logs"), - "INFO", - API_KEY, - false - ); + ProviderConfiguration invalidConfig = new ProviderConfiguration( + " ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY); assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) .isInstanceOf(IllegalArgumentException.class) @@ -516,25 +454,9 @@ class OpenAiHttpAdapterTest { @DisplayName("should handle empty API key gracefully") void testEmptyApiKeyHandled() throws Exception { // Arrange - StartConfiguration configWithEmptyKey = new StartConfiguration( - Paths.get("/source"), - Paths.get("/target"), - Paths.get("/db.sqlite"), - URI.create(API_BASE_URL), - API_MODEL, - TIMEOUT_SECONDS, - 5, - 100, - 5000, - Paths.get("/prompt.txt"), - Paths.get("/lock"), - Paths.get("/logs"), - "INFO", - "", // Empty key - false - ); - - OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(configWithEmptyKey, httpClient); + OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter( + new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""), + httpClient); HttpResponse httpResponse = mockHttpResponse(200, "{}"); when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); @@ -548,18 +470,94 @@ class OpenAiHttpAdapterTest { assertThat(result).isInstanceOf(AiInvocationSuccess.class); } + // ========================================================================= + // Mandatory AP-003 test cases + // ========================================================================= + + /** + * Verifies that the adapter reads all values from the new {@link ProviderConfiguration} + * namespace and uses them correctly in outgoing HTTP requests. + */ + @Test + @DisplayName("openAiAdapterReadsValuesFromNewNamespace: all ProviderConfiguration fields are used") + void openAiAdapterReadsValuesFromNewNamespace() throws Exception { + // Arrange: ProviderConfiguration with values distinct from setUp defaults + ProviderConfiguration nsConfig = new ProviderConfiguration( + "ns-model-v2", 20, "https://provider-ns.example.com", "ns-api-key-abc"); + OpenAiHttpAdapter nsAdapter = new OpenAiHttpAdapter(nsConfig, httpClient); + + HttpResponse httpResponse = mockHttpResponse(200, "{}"); + when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); + + AiRequestRepresentation request = createTestRequest("prompt", "document"); + nsAdapter.invoke(request); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any()); + HttpRequest capturedRequest = requestCaptor.getValue(); + + // Verify baseUrl from ProviderConfiguration + assertThat(capturedRequest.uri().toString()) + .as("baseUrl must come from ProviderConfiguration") + .startsWith("https://provider-ns.example.com"); + + // Verify apiKey from ProviderConfiguration + assertThat(capturedRequest.headers().firstValue("Authorization").orElse("")) + .as("apiKey must come from ProviderConfiguration") + .contains("ns-api-key-abc"); + + // Verify model from ProviderConfiguration + String body = nsAdapter.getLastBuiltJsonBodyForTesting(); + assertThat(new JSONObject(body).getString("model")) + .as("model must come from ProviderConfiguration") + .isEqualTo("ns-model-v2"); + + // Verify timeout from ProviderConfiguration + assertThat(capturedRequest.timeout()) + .as("timeout must come from ProviderConfiguration") + .isPresent() + .get() + .isEqualTo(Duration.ofSeconds(20)); + } + + /** + * Verifies that adapter behavioral contracts (success mapping, error classification) + * are unchanged after the constructor was changed from StartConfiguration to + * ProviderConfiguration. + */ + @Test + @DisplayName("openAiAdapterBehaviorIsUnchanged: HTTP success and error mapping contracts are preserved") + void openAiAdapterBehaviorIsUnchanged() throws Exception { + // Success case: HTTP 200 must produce AiInvocationSuccess with raw body + String successBody = "{\"choices\":[{\"message\":{\"content\":\"result\"}}]}"; + HttpResponse successResponse = mockHttpResponse(200, successBody); + when(httpClient.send(any(HttpRequest.class), any())) + .thenReturn((HttpResponse) successResponse); + + AiInvocationResult result = adapter.invoke(createTestRequest("p", "d")); + assertThat(result).isInstanceOf(AiInvocationSuccess.class); + assertThat(((AiInvocationSuccess) result).rawResponse().content()).isEqualTo(successBody); + + // Non-200 case: HTTP 429 must produce AiInvocationTechnicalFailure with HTTP_429 reason + HttpResponse rateLimitedResponse = mockHttpResponse(429, null); + when(httpClient.send(any(HttpRequest.class), any())) + .thenReturn((HttpResponse) rateLimitedResponse); + result = adapter.invoke(createTestRequest("p", "d")); + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429"); + + // Timeout case: HttpTimeoutException must produce TIMEOUT reason + when(httpClient.send(any(HttpRequest.class), any())) + .thenThrow(new HttpTimeoutException("timed out")); + result = adapter.invoke(createTestRequest("p", "d")); + assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class); + assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT"); + } + // Helper methods /** * Creates a mock HttpResponse with the specified status code and optional body. - *

    - * This helper method works around Mockito's type variance issues with generics - * by creating the mock with proper type handling. If body is null, the body() - * method is not stubbed to avoid unnecessary stubs. - * - * @param statusCode the HTTP status code - * @param body the response body (null to skip body stubbing) - * @return a mock HttpResponse configured with the given status and body */ @SuppressWarnings("unchecked") private HttpResponse mockHttpResponse(int statusCode, String body) { diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java index 62aa4da..f20223c 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/bootstrap/validation/StartConfigurationValidatorTest.java @@ -1,10 +1,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -23,6 +25,13 @@ class StartConfigurationValidatorTest { @TempDir Path tempDir; + /** Helper: builds a minimal valid multi-provider configuration for use in tests. */ + private static MultiProviderConfiguration validMultiProviderConfig() { + ProviderConfiguration openAiConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-key"); + return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null); + } + @Test void validate_successWithValidConfiguration() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); @@ -34,9 +43,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -44,7 +51,6 @@ class StartConfigurationValidatorTest { tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-api-key", false ); @@ -57,9 +63,7 @@ class StartConfigurationValidatorTest { null, tempDir.resolve("target"), tempDir.resolve("db.sqlite"), - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -67,7 +71,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -84,9 +87,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("source"), null, tempDir.resolve("db.sqlite"), - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -94,7 +95,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -111,9 +111,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("source"), tempDir.resolve("target"), null, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -121,7 +119,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -133,7 +130,7 @@ class StartConfigurationValidatorTest { } @Test - void validate_failsWhenApiBaseUrlIsNull() throws Exception { + void validate_failsWhenMultiProviderConfigurationIsNull() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); @@ -144,8 +141,6 @@ class StartConfigurationValidatorTest { targetFolder, sqliteFile, null, - "gpt-4", - 30, 3, 100, 50000, @@ -153,7 +148,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -161,39 +155,7 @@ class StartConfigurationValidatorTest { InvalidStartConfigurationException.class, () -> validator.validate(config) ); - assertTrue(exception.getMessage().contains("api.baseUrl: must not be null")); - } - - @Test - void validate_failsWhenApiModelIsNull() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("https://api.example.com"), - null, - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.model: must not be null or blank")); + assertTrue(exception.getMessage().contains("ai provider configuration: must not be null")); } @Test @@ -206,9 +168,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -216,7 +176,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -227,38 +186,6 @@ class StartConfigurationValidatorTest { assertTrue(exception.getMessage().contains("prompt.template.file: must not be null")); } - @Test - void validate_failsWhenApiTimeoutSecondsIsZeroOrNegative() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 0, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.timeoutSeconds: must be > 0")); - } - @Test void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); @@ -270,9 +197,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), -1, 100, 50000, @@ -280,7 +205,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -302,9 +226,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 0, 100, 50000, @@ -312,7 +234,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -335,9 +256,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 0, 50000, @@ -345,7 +264,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -367,9 +285,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, -1, @@ -377,7 +293,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -399,9 +314,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 1, // maxRetriesTransient = 1 is the minimum valid value 100, 50000, @@ -409,7 +322,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -427,9 +339,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 0, // maxTextCharacters = 0 ist ungültig @@ -437,7 +347,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -458,9 +367,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("nonexistent"), targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -468,7 +375,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -490,9 +396,7 @@ class StartConfigurationValidatorTest { sourceFile, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -500,7 +404,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -513,8 +416,6 @@ class StartConfigurationValidatorTest { @Test void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception { - // target.folder is "anlegbar" (creatable): parent tempDir exists, folder itself does not. - // The validator must create the folder and accept the configuration. Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); @@ -523,9 +424,7 @@ class StartConfigurationValidatorTest { sourceFolder, tempDir.resolve("nonexistent-target"), sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -533,7 +432,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -545,7 +443,6 @@ class StartConfigurationValidatorTest { @Test void validate_failsWhenTargetFolderCannotBeCreated() { - // Inject a TargetFolderChecker that simulates a creation failure. StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator( path -> null, // source folder checker always passes path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)" @@ -555,9 +452,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("source"), tempDir.resolve("uncreatable-target"), tempDir.resolve("db.sqlite"), - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -565,7 +460,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -588,9 +482,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFile, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -598,7 +490,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -619,9 +510,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, tempDir.resolve("nonexistent/db.sqlite"), - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -629,7 +518,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -640,70 +528,6 @@ class StartConfigurationValidatorTest { assertTrue(exception.getMessage().contains("sqlite.file: parent directory does not exist")); } - @Test - void validate_failsWhenApiBaseUrlIsNotAbsolute() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("/api/v1"), - "gpt-4", - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI")); - } - - @Test - void validate_failsWhenApiBaseUrlHasUnsupportedScheme() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("ftp://api.example.com"), - "gpt-4", - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.baseUrl: scheme must be http or https")); - } - @Test void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); @@ -714,9 +538,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -724,7 +546,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -746,9 +567,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -756,7 +575,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -777,9 +595,7 @@ class StartConfigurationValidatorTest { sameFolder, sameFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -787,7 +603,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -805,8 +620,6 @@ class StartConfigurationValidatorTest { null, null, null, - null, - 0, -1, 0, -1, @@ -814,7 +627,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -826,22 +638,13 @@ class StartConfigurationValidatorTest { assertTrue(message.contains("source.folder: must not be null")); assertTrue(message.contains("target.folder: must not be null")); assertTrue(message.contains("sqlite.file: must not be null")); - assertTrue(message.contains("api.baseUrl: must not be null")); - assertTrue(message.contains("api.model: must not be null or blank")); + assertTrue(message.contains("ai provider configuration: must not be null")); assertTrue(message.contains("prompt.template.file: must not be null")); - assertTrue(message.contains("api.timeoutSeconds: must be > 0")); assertTrue(message.contains("max.retries.transient: must be >= 1")); assertTrue(message.contains("max.pages: must be > 0")); assertTrue(message.contains("max.text.characters: must be > 0")); } - /** - * Focused tests for source folder validation using mocked filesystem checks. - *

    - * These tests verify the four critical paths for source folder validation without - * relying on platform-dependent filesystem permissions or the actual FS state. - */ - @Test void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception { Path targetFolder = Files.createDirectory(tempDir.resolve("target")); @@ -852,9 +655,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("nonexistent"), targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -862,11 +663,9 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); - // Mock: always return "does not exist" error for any path StartConfigurationValidator.SourceFolderChecker mockChecker = path -> "- source.folder: path does not exist: " + path; @@ -889,9 +688,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("somepath"), targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -899,11 +696,9 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); - // Mock: simulate path exists but is not a directory StartConfigurationValidator.SourceFolderChecker mockChecker = path -> "- source.folder: path is not a directory: " + path; @@ -926,9 +721,7 @@ class StartConfigurationValidatorTest { tempDir.resolve("somepath"), targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -936,12 +729,9 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); - // Mock: simulate path exists, is directory, but is not readable - // This is the critical case that is hard to test on actual FS StartConfigurationValidator.SourceFolderChecker mockChecker = path -> "- source.folder: directory is not readable: " + path; @@ -965,9 +755,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -975,11 +763,9 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); - // Mock: all checks pass (return null) StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null; StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); @@ -988,24 +774,19 @@ class StartConfigurationValidatorTest { "Validation should succeed when source folder checker returns null"); } - // Neue Tests zur Verbesserung der Abdeckung - @Test void validate_failsWhenSqliteFileHasNoParent() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - // Ein Pfad ohne Parent (z.B. einfacher Dateiname) Path sqliteFileWithoutParent = Path.of("db.sqlite"); StartConfiguration config = new StartConfiguration( sourceFolder, targetFolder, sqliteFileWithoutParent, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -1013,7 +794,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -1029,8 +809,7 @@ class StartConfigurationValidatorTest { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - // Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen + Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt")); Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite"); @@ -1038,9 +817,7 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFileWithFileAsParent, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -1048,7 +825,6 @@ class StartConfigurationValidatorTest { null, null, "INFO", - "test-api-key", false ); @@ -1059,70 +835,6 @@ class StartConfigurationValidatorTest { assertTrue(exception.getMessage().contains("sqlite.file: parent is not a directory")); } - @Test - void validate_apiModelBlankString() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("https://api.example.com"), - " ", // Blank string - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.model: must not be null or blank")); - } - - @Test - void validate_apiModelEmptyString() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("https://api.example.com"), - "", // Empty string - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - assertTrue(exception.getMessage().contains("api.model: must not be null or blank")); - } - @Test void validate_runtimeLockFileParentDoesNotExist() throws Exception { Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); @@ -1134,17 +846,14 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, promptTemplateFile, - tempDir.resolve("nonexistent/lock.lock"), // Lock file mit nicht existierendem Parent + tempDir.resolve("nonexistent/lock.lock"), null, "INFO", - "test-api-key", false ); @@ -1161,8 +870,7 @@ class StartConfigurationValidatorTest { Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - // Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen + Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt")); Path lockFileWithFileAsParent = parentFile.resolve("lock.lock"); @@ -1170,17 +878,14 @@ class StartConfigurationValidatorTest { sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, promptTemplateFile, - lockFileWithFileAsParent, // Lock file mit Datei als Parent + lockFileWithFileAsParent, null, "INFO", - "test-api-key", false ); @@ -1197,25 +902,21 @@ class StartConfigurationValidatorTest { Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - // Erstelle eine Datei, die als Log-Verzeichnis verwendet wird + Path logFileInsteadOfDirectory = Files.createFile(tempDir.resolve("logfile.txt")); StartConfiguration config = new StartConfiguration( sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, promptTemplateFile, null, - logFileInsteadOfDirectory, // Datei statt Verzeichnis + logFileInsteadOfDirectory, "INFO", - "test-api-key", false ); @@ -1225,66 +926,4 @@ class StartConfigurationValidatorTest { ); assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory")); } - - @Test - void validate_apiBaseUrlHttpScheme() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("http://api.example.com"), // HTTP statt HTTPS - "gpt-4", - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - assertDoesNotThrow(() -> validator.validate(config), - "HTTP scheme should be valid"); - } - - @Test - void validate_apiBaseUrlNullScheme() throws Exception { - Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); - Path targetFolder = Files.createDirectory(tempDir.resolve("target")); - Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); - Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); - - StartConfiguration config = new StartConfiguration( - sourceFolder, - targetFolder, - sqliteFile, - URI.create("//api.example.com"), // Kein Schema - "gpt-4", - 30, - 3, - 100, - 50000, - promptTemplateFile, - null, - null, - "INFO", - "test-api-key", - false - ); - - InvalidStartConfigurationException exception = assertThrows( - InvalidStartConfigurationException.class, - () -> validator.validate(config) - ); - // Bei einer URI ohne Schema ist sie nicht absolut, daher kommt zuerst diese Fehlermeldung - assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI")); - } -} \ No newline at end of file +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java new file mode 100644 index 0000000..3bf216f --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java @@ -0,0 +1,351 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.configuration; + +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link LegacyConfigurationMigrator}. + *

    + * Covers all mandatory test cases for the legacy-to-multi-provider configuration migration. + * Temporary files are managed via {@link TempDir} so no test artifacts remain on the file system. + */ +class LegacyConfigurationMigratorTest { + + @TempDir + Path tempDir; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Full legacy configuration containing all four api.* keys plus other required keys. */ + private static String fullLegacyContent() { + return "source.folder=./source\n" + + "target.folder=./target\n" + + "sqlite.file=./db.sqlite\n" + + "api.baseUrl=https://api.openai.com/v1\n" + + "api.model=gpt-4o\n" + + "api.timeoutSeconds=30\n" + + "max.retries.transient=3\n" + + "max.pages=10\n" + + "max.text.characters=5000\n" + + "prompt.template.file=./prompt.txt\n" + + "api.key=sk-test-legacy-key\n" + + "log.level=INFO\n" + + "log.ai.sensitive=false\n"; + } + + private Path writeLegacyFile(String name, String content) throws IOException { + Path file = tempDir.resolve(name); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private Properties loadProperties(Path file) throws IOException { + Properties props = new Properties(); + props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8)); + return props; + } + + private LegacyConfigurationMigrator defaultMigrator() { + return new LegacyConfigurationMigrator(); + } + + // ========================================================================= + // Mandatory test case 1 + // ========================================================================= + + /** + * Legacy file with all four {@code api.*} keys is correctly migrated. + * Values in the migrated file must be identical to the originals; all other keys survive. + */ + @Test + void migratesLegacyFileWithAllFlatKeys() throws IOException { + Path file = writeLegacyFile("app.properties", fullLegacyContent()); + + defaultMigrator().migrateIfLegacy(file); + + Properties migrated = loadProperties(file); + assertEquals("https://api.openai.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl")); + assertEquals("gpt-4o", migrated.getProperty("ai.provider.openai-compatible.model")); + assertEquals("30", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds")); + assertEquals("sk-test-legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey")); + assertEquals("openai-compatible", migrated.getProperty("ai.provider.active")); + + // Old flat keys must be gone + assertFalse(migrated.containsKey("api.baseUrl"), "api.baseUrl must be removed"); + assertFalse(migrated.containsKey("api.model"), "api.model must be removed"); + assertFalse(migrated.containsKey("api.timeoutSeconds"), "api.timeoutSeconds must be removed"); + assertFalse(migrated.containsKey("api.key"), "api.key must be removed"); + } + + // ========================================================================= + // Mandatory test case 2 + // ========================================================================= + + /** + * A {@code .bak} backup is created with the exact original content before any changes. + */ + @Test + void createsBakBeforeOverwriting() throws IOException { + String original = fullLegacyContent(); + Path file = writeLegacyFile("app.properties", original); + Path bakFile = tempDir.resolve("app.properties.bak"); + + assertFalse(Files.exists(bakFile), "No .bak should exist before migration"); + + defaultMigrator().migrateIfLegacy(file); + + assertTrue(Files.exists(bakFile), ".bak must be created during migration"); + assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8), + ".bak must contain the exact original content"); + } + + // ========================================================================= + // Mandatory test case 3 + // ========================================================================= + + /** + * When {@code .bak} already exists, the new backup is written as {@code .bak.1}. + * Neither the existing {@code .bak} nor the new {@code .bak.1} is overwritten. + */ + @Test + void bakSuffixIsIncrementedIfBakExists() throws IOException { + String original = fullLegacyContent(); + Path file = writeLegacyFile("app.properties", original); + + // Pre-create .bak with different content + Path existingBak = tempDir.resolve("app.properties.bak"); + Files.writeString(existingBak, "# existing bak", StandardCharsets.UTF_8); + + defaultMigrator().migrateIfLegacy(file); + + // Existing .bak must be untouched + assertEquals("# existing bak", Files.readString(existingBak, StandardCharsets.UTF_8), + "Existing .bak must not be overwritten"); + + // New backup must be .bak.1 with original content + Path newBak = tempDir.resolve("app.properties.bak.1"); + assertTrue(Files.exists(newBak), ".bak.1 must be created when .bak already exists"); + assertEquals(original, Files.readString(newBak, StandardCharsets.UTF_8), + ".bak.1 must contain the original content"); + } + + // ========================================================================= + // Mandatory test case 4 + // ========================================================================= + + /** + * A file already in the new multi-provider schema triggers no write and no {@code .bak}. + */ + @Test + void noOpForAlreadyMigratedFile() throws IOException { + String newSchema = "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1\n" + + "ai.provider.openai-compatible.model=gpt-4o\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=sk-key\n"; + Path file = writeLegacyFile("app.properties", newSchema); + long modifiedBefore = Files.getLastModifiedTime(file).toMillis(); + + defaultMigrator().migrateIfLegacy(file); + + // File must not have been rewritten + assertEquals(modifiedBefore, Files.getLastModifiedTime(file).toMillis(), + "File modification time must not change for already-migrated files"); + + // No .bak should exist + Path bakFile = tempDir.resolve("app.properties.bak"); + assertFalse(Files.exists(bakFile), "No .bak must be created for already-migrated files"); + } + + // ========================================================================= + // Mandatory test case 5 + // ========================================================================= + + /** + * After migration, the new parser and validator load the file without error. + */ + @Test + void reloadAfterMigrationSucceeds() throws IOException { + Path file = writeLegacyFile("app.properties", fullLegacyContent()); + + defaultMigrator().migrateIfLegacy(file); + + // Reload and parse with the new parser+validator — must not throw + Properties props = loadProperties(file); + MultiProviderConfiguration config = assertDoesNotThrow( + () -> new MultiProviderConfigurationParser().parse(props), + "Migrated file must be parseable by MultiProviderConfigurationParser"); + assertDoesNotThrow( + () -> new MultiProviderConfigurationValidator().validate(config), + "Migrated file must pass MultiProviderConfigurationValidator"); + } + + // ========================================================================= + // Mandatory test case 6 + // ========================================================================= + + /** + * When post-migration validation fails, a {@link ConfigurationLoadingException} is thrown + * and the {@code .bak} backup is preserved with the original content. + */ + @Test + void migrationFailureKeepsBak() throws IOException { + String original = fullLegacyContent(); + Path file = writeLegacyFile("app.properties", original); + + // Validator that always rejects + MultiProviderConfigurationValidator failingValidator = new MultiProviderConfigurationValidator() { + @Override + public void validate(MultiProviderConfiguration config) { + throw new InvalidStartConfigurationException("Simulated validation failure"); + } + }; + + LegacyConfigurationMigrator migrator = new LegacyConfigurationMigrator( + new MultiProviderConfigurationParser(), failingValidator); + + assertThrows(ConfigurationLoadingException.class, + () -> migrator.migrateIfLegacy(file), + "Migration must throw ConfigurationLoadingException when post-migration validation fails"); + + // .bak must be preserved with original content + Path bakFile = tempDir.resolve("app.properties.bak"); + assertTrue(Files.exists(bakFile), ".bak must be preserved after migration failure"); + assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8), + ".bak content must match the original file content"); + } + + // ========================================================================= + // Mandatory test case 7 + // ========================================================================= + + /** + * A file that contains {@code ai.provider.active} but no legacy {@code api.*} keys + * is not considered legacy and triggers no migration. + */ + @Test + void legacyDetectionRequiresAtLeastOneFlatKey() throws IOException { + String notLegacy = "ai.provider.active=openai-compatible\n" + + "source.folder=./source\n" + + "max.pages=10\n"; + Path file = writeLegacyFile("app.properties", notLegacy); + + Properties props = new Properties(); + props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8)); + + boolean detected = defaultMigrator().isLegacyForm(props); + + assertFalse(detected, "File with ai.provider.active and no api.* keys must not be detected as legacy"); + } + + // ========================================================================= + // Mandatory test case 8 + // ========================================================================= + + /** + * The four legacy values land in exactly the target keys in the openai-compatible namespace, + * and {@code ai.provider.active} is set to {@code openai-compatible}. + */ + @Test + void legacyValuesEndUpInOpenAiCompatibleNamespace() throws IOException { + String content = "api.baseUrl=https://legacy.example.com/v1\n" + + "api.model=legacy-model\n" + + "api.timeoutSeconds=42\n" + + "api.key=legacy-key\n" + + "source.folder=./src\n"; + Path file = writeLegacyFile("app.properties", content); + + defaultMigrator().migrateIfLegacy(file); + + Properties migrated = loadProperties(file); + assertEquals("https://legacy.example.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"), + "api.baseUrl must map to ai.provider.openai-compatible.baseUrl"); + assertEquals("legacy-model", migrated.getProperty("ai.provider.openai-compatible.model"), + "api.model must map to ai.provider.openai-compatible.model"); + assertEquals("42", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"), + "api.timeoutSeconds must map to ai.provider.openai-compatible.timeoutSeconds"); + assertEquals("legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"), + "api.key must map to ai.provider.openai-compatible.apiKey"); + assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"), + "ai.provider.active must be set to openai-compatible"); + } + + // ========================================================================= + // Mandatory test case 9 + // ========================================================================= + + /** + * Keys unrelated to the legacy api.* set survive the migration with identical values. + */ + @Test + void unrelatedKeysSurviveUnchanged() throws IOException { + String content = "source.folder=./my/source\n" + + "target.folder=./my/target\n" + + "sqlite.file=./my/db.sqlite\n" + + "max.pages=15\n" + + "max.text.characters=3000\n" + + "log.level=DEBUG\n" + + "log.ai.sensitive=false\n" + + "api.baseUrl=https://api.openai.com/v1\n" + + "api.model=gpt-4o\n" + + "api.timeoutSeconds=30\n" + + "api.key=sk-unrelated-test\n"; + Path file = writeLegacyFile("app.properties", content); + + defaultMigrator().migrateIfLegacy(file); + + Properties migrated = loadProperties(file); + assertEquals("./my/source", migrated.getProperty("source.folder"), "source.folder must be unchanged"); + assertEquals("./my/target", migrated.getProperty("target.folder"), "target.folder must be unchanged"); + assertEquals("./my/db.sqlite", migrated.getProperty("sqlite.file"), "sqlite.file must be unchanged"); + assertEquals("15", migrated.getProperty("max.pages"), "max.pages must be unchanged"); + assertEquals("3000", migrated.getProperty("max.text.characters"), "max.text.characters must be unchanged"); + assertEquals("DEBUG", migrated.getProperty("log.level"), "log.level must be unchanged"); + assertEquals("false", migrated.getProperty("log.ai.sensitive"), "log.ai.sensitive must be unchanged"); + } + + // ========================================================================= + // Mandatory test case 10 + // ========================================================================= + + /** + * Migration writes via a temporary {@code .tmp} file followed by a move/rename. + * After successful migration, no {@code .tmp} file remains, and the original path + * holds the fully migrated content (never partially overwritten). + */ + @Test + void inPlaceWriteIsAtomic() throws IOException { + Path file = writeLegacyFile("app.properties", fullLegacyContent()); + Path tmpFile = tempDir.resolve("app.properties.tmp"); + + defaultMigrator().migrateIfLegacy(file); + + // .tmp must have been cleaned up (moved to target, not left behind) + assertFalse(Files.exists(tmpFile), + ".tmp file must not exist after migration (must have been moved to target)"); + + // Target must contain migrated content + Properties migrated = loadProperties(file); + assertTrue(migrated.containsKey("ai.provider.active"), + "Migrated file must contain ai.provider.active (complete write confirmed)"); + assertTrue(migrated.containsKey("ai.provider.openai-compatible.model"), + "Migrated file must contain the new namespaced model key (complete write confirmed)"); + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java new file mode 100644 index 0000000..4d7a5e3 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java @@ -0,0 +1,281 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.configuration; + +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; + +import org.junit.jupiter.api.Test; + +import java.util.Properties; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the multi-provider configuration parsing and validation pipeline. + *

    + * Covers all mandatory test cases for the new configuration schema as defined + * in the active work package specification. + */ +class MultiProviderConfigurationTest { + + private static final Function NO_ENV = key -> null; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private Properties fullOpenAiProperties() { + Properties props = new Properties(); + props.setProperty("ai.provider.active", "openai-compatible"); + props.setProperty("ai.provider.openai-compatible.baseUrl", "https://api.openai.com"); + props.setProperty("ai.provider.openai-compatible.model", "gpt-4o"); + props.setProperty("ai.provider.openai-compatible.timeoutSeconds", "30"); + props.setProperty("ai.provider.openai-compatible.apiKey", "sk-openai-test"); + // Claude side intentionally not set (inactive) + return props; + } + + private Properties fullClaudeProperties() { + Properties props = new Properties(); + props.setProperty("ai.provider.active", "claude"); + props.setProperty("ai.provider.claude.baseUrl", "https://api.anthropic.com"); + props.setProperty("ai.provider.claude.model", "claude-3-5-sonnet-20241022"); + props.setProperty("ai.provider.claude.timeoutSeconds", "60"); + props.setProperty("ai.provider.claude.apiKey", "sk-ant-test"); + // OpenAI side intentionally not set (inactive) + return props; + } + + private MultiProviderConfiguration parseAndValidate(Properties props, + Function envLookup) { + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(envLookup); + MultiProviderConfiguration config = parser.parse(props); + new MultiProviderConfigurationValidator().validate(config); + return config; + } + + private MultiProviderConfiguration parseAndValidate(Properties props) { + return parseAndValidate(props, NO_ENV); + } + + // ========================================================================= + // Mandatory test case 1 + // ========================================================================= + + /** + * Full new schema with OpenAI-compatible active, all required values present. + * Parser and validator must both succeed. + */ + @Test + void parsesNewSchemaWithOpenAiCompatibleActive() { + MultiProviderConfiguration config = parseAndValidate(fullOpenAiProperties()); + + assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily()); + assertEquals("gpt-4o", config.openAiCompatibleConfig().model()); + assertEquals(30, config.openAiCompatibleConfig().timeoutSeconds()); + assertEquals("https://api.openai.com", config.openAiCompatibleConfig().baseUrl()); + assertEquals("sk-openai-test", config.openAiCompatibleConfig().apiKey()); + } + + // ========================================================================= + // Mandatory test case 2 + // ========================================================================= + + /** + * Full new schema with Claude active, all required values present. + * Parser and validator must both succeed. + */ + @Test + void parsesNewSchemaWithClaudeActive() { + MultiProviderConfiguration config = parseAndValidate(fullClaudeProperties()); + + assertEquals(AiProviderFamily.CLAUDE, config.activeProviderFamily()); + assertEquals("claude-3-5-sonnet-20241022", config.claudeConfig().model()); + assertEquals(60, config.claudeConfig().timeoutSeconds()); + assertEquals("https://api.anthropic.com", config.claudeConfig().baseUrl()); + assertEquals("sk-ant-test", config.claudeConfig().apiKey()); + } + + // ========================================================================= + // Mandatory test case 3 + // ========================================================================= + + /** + * Claude active, {@code ai.provider.claude.baseUrl} absent. + * The default {@code https://api.anthropic.com} must be applied; validation must pass. + */ + @Test + void claudeBaseUrlDefaultsWhenMissing() { + Properties props = fullClaudeProperties(); + props.remove("ai.provider.claude.baseUrl"); + + MultiProviderConfiguration config = parseAndValidate(props); + + assertNotNull(config.claudeConfig().baseUrl(), + "baseUrl must not be null when Claude default is applied"); + assertEquals(MultiProviderConfigurationParser.CLAUDE_DEFAULT_BASE_URL, + config.claudeConfig().baseUrl(), + "Default Claude baseUrl must be https://api.anthropic.com"); + } + + // ========================================================================= + // Mandatory test case 4 + // ========================================================================= + + /** + * {@code ai.provider.active} is absent. Parser must throw with a clear message. + */ + @Test + void rejectsMissingActiveProvider() { + Properties props = fullOpenAiProperties(); + props.remove("ai.provider.active"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + ConfigurationLoadingException ex = assertThrows( + ConfigurationLoadingException.class, + () -> parser.parse(props)); + + assertTrue(ex.getMessage().contains("ai.provider.active"), + "Error message must reference the missing property"); + } + + // ========================================================================= + // Mandatory test case 5 + // ========================================================================= + + /** + * {@code ai.provider.active=foo} – unrecognised value. Parser must throw. + */ + @Test + void rejectsUnknownActiveProvider() { + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.active", "foo"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + ConfigurationLoadingException ex = assertThrows( + ConfigurationLoadingException.class, + () -> parser.parse(props)); + + assertTrue(ex.getMessage().contains("foo"), + "Error message must include the unrecognised value"); + } + + // ========================================================================= + // Mandatory test case 6 + // ========================================================================= + + /** + * Active provider has a mandatory field blank (model removed). Validation must fail. + */ + @Test + void rejectsMissingMandatoryFieldForActiveProvider() { + Properties props = fullOpenAiProperties(); + props.remove("ai.provider.openai-compatible.model"); + + MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV); + MultiProviderConfiguration config = parser.parse(props); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> new MultiProviderConfigurationValidator().validate(config)); + + assertTrue(ex.getMessage().contains("model"), + "Error message must mention the missing field"); + } + + // ========================================================================= + // Mandatory test case 7 + // ========================================================================= + + /** + * Inactive provider has incomplete configuration (Claude fields missing while OpenAI is active). + * Validation must pass; inactive provider fields are not required. + */ + @Test + void acceptsMissingMandatoryFieldForInactiveProvider() { + // OpenAI active, Claude completely unconfigured + Properties props = fullOpenAiProperties(); + // No ai.provider.claude.* keys set + + MultiProviderConfiguration config = parseAndValidate(props); + + assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily(), + "Active provider must be openai-compatible"); + // Claude config may have null/blank fields – no exception expected + } + + // ========================================================================= + // Mandatory test case 8 + // ========================================================================= + + /** + * Environment variable for the active provider overrides the properties value. + *

    + * Sub-case A: {@code OPENAI_COMPATIBLE_API_KEY} set, OpenAI active. + * Sub-case B: {@code ANTHROPIC_API_KEY} set, Claude active. + */ + @Test + void envVarOverridesPropertiesApiKeyForActiveProvider() { + // Sub-case A: OpenAI active, OPENAI_COMPATIBLE_API_KEY set + Properties openAiProps = fullOpenAiProperties(); + openAiProps.setProperty("ai.provider.openai-compatible.apiKey", "properties-key"); + + Function envWithOpenAiKey = key -> + MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key) + ? "env-openai-key" : null; + + MultiProviderConfiguration openAiConfig = parseAndValidate(openAiProps, envWithOpenAiKey); + assertEquals("env-openai-key", openAiConfig.openAiCompatibleConfig().apiKey(), + "Env var must override properties API key for OpenAI-compatible"); + + // Sub-case B: Claude active, ANTHROPIC_API_KEY set + Properties claudeProps = fullClaudeProperties(); + claudeProps.setProperty("ai.provider.claude.apiKey", "properties-key"); + + Function envWithClaudeKey = key -> + MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key) + ? "env-claude-key" : null; + + MultiProviderConfiguration claudeConfig = parseAndValidate(claudeProps, envWithClaudeKey); + assertEquals("env-claude-key", claudeConfig.claudeConfig().apiKey(), + "Env var must override properties API key for Claude"); + } + + // ========================================================================= + // Mandatory test case 9 + // ========================================================================= + + /** + * Environment variable is set only for the inactive provider. + * The active provider must use its own properties value; the inactive provider's + * env var must not affect the active provider's resolved key. + */ + @Test + void envVarOnlyResolvesForActiveProvider() { + // OpenAI is active with a properties apiKey. + // ANTHROPIC_API_KEY is set (for the inactive Claude provider). + // The OpenAI config must use its properties key, not the Anthropic env var. + Properties props = fullOpenAiProperties(); + props.setProperty("ai.provider.openai-compatible.apiKey", "openai-properties-key"); + + Function envWithClaudeKeyOnly = key -> + MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key) + ? "anthropic-env-key" : null; + + MultiProviderConfiguration config = parseAndValidate(props, envWithClaudeKeyOnly); + + assertEquals("openai-properties-key", + config.openAiCompatibleConfig().apiKey(), + "Active provider (OpenAI) must use its own properties key, " + + "not the inactive provider's env var"); + // The Anthropic env var IS applied to the Claude config (inactive), + // but that does not affect the active provider. + assertEquals("anthropic-env-key", + config.claudeConfig().apiKey(), + "Inactive Claude config should still pick up its own env var"); + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java index 7224425..186f06f 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/PropertiesConfigurationPortAdapterTest.java @@ -11,6 +11,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.function.Function; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -19,7 +21,8 @@ import org.junit.jupiter.api.io.TempDir; * Unit tests for {@link PropertiesConfigurationPortAdapter}. *

    * Tests cover valid configuration loading, missing mandatory properties, - * invalid property values, and API-key environment variable precedence. + * invalid property values, and API-key environment variable precedence + * for the multi-provider schema. */ class PropertiesConfigurationPortAdapterTest { @@ -42,13 +45,20 @@ class PropertiesConfigurationPortAdapterTest { var config = adapter.loadConfiguration(); assertNotNull(config); - // Use endsWith to handle platform-specific path separators assertTrue(config.sourceFolder().toString().endsWith("source")); assertTrue(config.targetFolder().toString().endsWith("target")); assertTrue(config.sqliteFile().toString().endsWith("db.sqlite")); - assertEquals("https://api.example.com", config.apiBaseUrl().toString()); - assertEquals("gpt-4", config.apiModel()); - assertEquals(30, config.apiTimeoutSeconds()); + assertNotNull(config.multiProviderConfiguration()); + assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, + config.multiProviderConfiguration().activeProviderFamily()); + assertEquals("https://api.example.com", + config.multiProviderConfiguration().activeProviderConfiguration().baseUrl()); + assertEquals("gpt-4", + config.multiProviderConfiguration().activeProviderConfiguration().model()); + assertEquals(30, + config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds()); + assertEquals("test-api-key-from-properties", + config.multiProviderConfiguration().activeProviderConfiguration().apiKey()); assertEquals(3, config.maxRetriesTransient()); assertEquals(100, config.maxPages()); assertEquals(50000, config.maxTextCharacters()); @@ -56,57 +66,60 @@ class PropertiesConfigurationPortAdapterTest { assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock")); assertTrue(config.logDirectory().toString().endsWith("logs")); assertEquals("DEBUG", config.logLevel()); - assertEquals("test-api-key-from-properties", config.apiKey()); } @Test - void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsAbsent() throws Exception { + void loadConfiguration_rejectsBlankApiKeyWhenAbsentAndNoEnvVar() throws Exception { Path configFile = createConfigFile("no-api-key.properties"); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); - var config = adapter.loadConfiguration(); - - assertEquals("", config.apiKey(), "API key should be empty when not in properties and no env var"); + assertThrows( + de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class, + adapter::loadConfiguration, + "Missing API key must be rejected as invalid configuration"); } @Test - void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsNull() throws Exception { + void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsNull() throws Exception { Path configFile = createConfigFile("no-api-key.properties"); Function envLookup = key -> null; PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile); - var config = adapter.loadConfiguration(); - - assertEquals("", config.apiKey()); + assertThrows( + de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class, + adapter::loadConfiguration, + "Null env var with no properties API key must be rejected as invalid configuration"); } @Test - void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsEmpty() throws Exception { + void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsEmpty() throws Exception { Path configFile = createConfigFile("no-api-key.properties"); Function envLookup = key -> ""; PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile); - var config = adapter.loadConfiguration(); - - assertEquals("", config.apiKey(), "Empty env var should fall back to empty string"); + assertThrows( + de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class, + adapter::loadConfiguration, + "Empty env var with no properties API key must be rejected as invalid configuration"); } @Test - void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsBlank() throws Exception { + void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsBlank() throws Exception { Path configFile = createConfigFile("no-api-key.properties"); Function envLookup = key -> " "; PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile); - var config = adapter.loadConfiguration(); - - assertEquals("", config.apiKey(), "Blank env var should fall back to empty string"); + assertThrows( + de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class, + adapter::loadConfiguration, + "Blank env var with no properties API key must be rejected as invalid configuration"); } @Test @@ -114,7 +127,7 @@ class PropertiesConfigurationPortAdapterTest { Path configFile = createConfigFile("valid-config.properties"); Function envLookup = key -> { - if ("PDF_UMBENENNER_API_KEY".equals(key)) { + if (MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)) { return "env-api-key-override"; } return null; @@ -124,7 +137,9 @@ class PropertiesConfigurationPortAdapterTest { var config = adapter.loadConfiguration(); - assertEquals("env-api-key-override", config.apiKey(), "Environment variable should override properties"); + assertEquals("env-api-key-override", + config.multiProviderConfiguration().activeProviderConfiguration().apiKey(), + "Environment variable must override properties API key"); } @Test @@ -163,21 +178,22 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=60\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=60\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=5\n" + "max.pages=200\n" + "max.text.characters=100000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" ); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); var config = adapter.loadConfiguration(); - assertEquals(60, config.apiTimeoutSeconds()); + assertEquals(60, config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds()); assertEquals(5, config.maxRetriesTransient()); assertEquals(200, config.maxPages()); assertEquals(100000, config.maxTextCharacters()); @@ -189,21 +205,24 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds= 45 \n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds= 45 \n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=2\n" + "max.pages=150\n" + "max.text.characters=75000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" ); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); var config = adapter.loadConfiguration(); - assertEquals(45, config.apiTimeoutSeconds(), "Whitespace should be trimmed from integer values"); + assertEquals(45, + config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds(), + "Whitespace should be trimmed from integer values"); } @Test @@ -212,14 +231,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=not-a-number\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=not-a-number\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=2\n" + "max.pages=150\n" + "max.text.characters=75000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" ); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); @@ -233,26 +253,28 @@ class PropertiesConfigurationPortAdapterTest { } @Test - void loadConfiguration_parsesUriCorrectly() throws Exception { + void loadConfiguration_parsesBaseUrlStringCorrectly() throws Exception { Path configFile = createInlineConfig( "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com:8080/v1\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com:8080/v1\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" ); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); var config = adapter.loadConfiguration(); - assertEquals("https://api.example.com:8080/v1", config.apiBaseUrl().toString()); + assertEquals("https://api.example.com:8080/v1", + config.multiProviderConfiguration().activeProviderConfiguration().baseUrl()); } @Test @@ -261,14 +283,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" ); PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile); @@ -282,26 +305,28 @@ class PropertiesConfigurationPortAdapterTest { @Test void allConfigurationFailuresAreClassifiedAsConfigurationLoadingException() throws Exception { - // Verify that file I/O failure uses ConfigurationLoadingException + // File I/O failure Path nonExistentFile = tempDir.resolve("nonexistent.properties"); PropertiesConfigurationPortAdapter adapter1 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, nonExistentFile); assertThrows(ConfigurationLoadingException.class, () -> adapter1.loadConfiguration(), "File I/O failure should throw ConfigurationLoadingException"); - // Verify that missing required property uses ConfigurationLoadingException + // Missing required property Path missingPropFile = createConfigFile("missing-required.properties"); PropertiesConfigurationPortAdapter adapter2 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, missingPropFile); assertThrows(ConfigurationLoadingException.class, () -> adapter2.loadConfiguration(), "Missing required property should throw ConfigurationLoadingException"); - // Verify that invalid integer value uses ConfigurationLoadingException + // Invalid integer value Path invalidIntFile = createInlineConfig( "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=invalid\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=invalid\n" + + "ai.provider.openai-compatible.apiKey=key\n" + "max.retries.transient=2\n" + "max.pages=100\n" + "max.text.characters=50000\n" + @@ -311,22 +336,20 @@ class PropertiesConfigurationPortAdapterTest { assertThrows(ConfigurationLoadingException.class, () -> adapter3.loadConfiguration(), "Invalid integer value should throw ConfigurationLoadingException"); - // Verify that invalid URI value uses ConfigurationLoadingException - Path invalidUriFile = createInlineConfig( + // Unknown ai.provider.active value + Path unknownProviderFile = createInlineConfig( "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=not a valid uri\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=unknown-provider\n" + "max.retries.transient=2\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" ); - PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, invalidUriFile); + PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, unknownProviderFile); assertThrows(ConfigurationLoadingException.class, () -> adapter4.loadConfiguration(), - "Invalid URI value should throw ConfigurationLoadingException"); + "Unknown provider identifier should throw ConfigurationLoadingException"); } @Test @@ -335,14 +358,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + - "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "prompt.template.file=/tmp/prompt.txt\n" // log.ai.sensitive intentionally omitted ); @@ -360,14 +384,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=true\n" ); @@ -385,14 +410,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=false\n" ); @@ -410,14 +436,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=TRUE\n" ); @@ -435,14 +462,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=FALSE\n" ); @@ -460,14 +488,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=maybe\n" ); @@ -490,14 +519,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=yes\n" ); @@ -518,14 +548,15 @@ class PropertiesConfigurationPortAdapterTest { "source.folder=/tmp/source\n" + "target.folder=/tmp/target\n" + "sqlite.file=/tmp/db.sqlite\n" + - "api.baseUrl=https://api.example.com\n" + - "api.model=gpt-4\n" + - "api.timeoutSeconds=30\n" + + "ai.provider.active=openai-compatible\n" + + "ai.provider.openai-compatible.baseUrl=https://api.example.com\n" + + "ai.provider.openai-compatible.model=gpt-4\n" + + "ai.provider.openai-compatible.timeoutSeconds=30\n" + + "ai.provider.openai-compatible.apiKey=test-key\n" + "max.retries.transient=3\n" + "max.pages=100\n" + "max.text.characters=50000\n" + "prompt.template.file=/tmp/prompt.txt\n" + - "api.key=test-key\n" + "log.ai.sensitive=1\n" ); @@ -544,7 +575,6 @@ class PropertiesConfigurationPortAdapterTest { Path sourceResource = Path.of("src/test/resources", resourceName); Path targetConfigFile = tempDir.resolve("application.properties"); - // Copy content from resource file Files.copy(sourceResource, targetConfigFile); return targetConfigFile; } @@ -556,4 +586,4 @@ class PropertiesConfigurationPortAdapterTest { } return configFile; } -} \ No newline at end of file +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteAttemptProviderPersistenceTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteAttemptProviderPersistenceTest.java new file mode 100644 index 0000000..180cf7f --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteAttemptProviderPersistenceTest.java @@ -0,0 +1,394 @@ +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.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +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 for the additive {@code ai_provider} column in {@code processing_attempt}. + *

    + * Covers schema migration (idempotency, nullable default for existing rows), + * write/read round-trips for both supported provider identifiers, and + * backward compatibility with databases created before provider tracking was introduced. + */ +class SqliteAttemptProviderPersistenceTest { + + private String jdbcUrl; + private SqliteSchemaInitializationAdapter schemaAdapter; + private SqliteProcessingAttemptRepositoryAdapter repository; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + Path dbFile = tempDir.resolve("provider-test.db"); + jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath(); + schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl); + repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + } + + // ------------------------------------------------------------------------- + // Schema migration tests + // ------------------------------------------------------------------------- + + /** + * A fresh database must contain the {@code ai_provider} column after schema initialisation. + */ + @Test + void addsProviderColumnOnFreshDb() throws SQLException { + schemaAdapter.initializeSchema(); + + assertThat(columnExists("processing_attempt", "ai_provider")) + .as("ai_provider column must exist in processing_attempt after fresh schema init") + .isTrue(); + } + + /** + * A database that already has the {@code processing_attempt} table without + * {@code ai_provider} (simulating an existing installation before this column was added) + * must receive the column via the idempotent schema evolution. + */ + @Test + void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException { + // Bootstrap schema without the ai_provider column (simulate legacy DB) + createLegacySchema(); + + assertThat(columnExists("processing_attempt", "ai_provider")) + .as("ai_provider must not be present before evolution") + .isFalse(); + + // Running initializeSchema must add the column + schemaAdapter.initializeSchema(); + + assertThat(columnExists("processing_attempt", "ai_provider")) + .as("ai_provider column must be added by schema evolution") + .isTrue(); + } + + /** + * Running schema initialisation multiple times must not fail and must not change the schema. + */ + @Test + void migrationIsIdempotent() throws SQLException { + schemaAdapter.initializeSchema(); + // Second and third init must not throw or change the schema + schemaAdapter.initializeSchema(); + schemaAdapter.initializeSchema(); + + assertThat(columnExists("processing_attempt", "ai_provider")) + .as("Column must still be present after repeated init calls") + .isTrue(); + } + + /** + * Rows that existed before the {@code ai_provider} column was added must have + * {@code NULL} as the column value, not a non-null default. + */ + @Test + void existingRowsKeepNullProvider() throws SQLException { + // Create legacy schema and insert a row without ai_provider + createLegacySchema(); + DocumentFingerprint fp = fingerprint("aa"); + insertLegacyDocumentRecord(fp); + insertLegacyAttemptRow(fp, "READY_FOR_AI"); + + // Now evolve the schema + schemaAdapter.initializeSchema(); + + // Read the existing row — ai_provider must be NULL + List attempts = repository.findAllByFingerprint(fp); + assertThat(attempts).hasSize(1); + assertThat(attempts.get(0).aiProvider()) + .as("Existing rows must have NULL ai_provider after schema evolution") + .isNull(); + } + + // ------------------------------------------------------------------------- + // Write tests + // ------------------------------------------------------------------------- + + /** + * A new attempt written with an active OpenAI-compatible provider must + * persist {@code "openai-compatible"} in {@code ai_provider}. + */ + @Test + void newAttemptsWriteOpenAiCompatibleProvider() { + schemaAdapter.initializeSchema(); + DocumentFingerprint fp = fingerprint("bb"); + insertDocumentRecord(fp); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + ProcessingAttempt attempt = new ProcessingAttempt( + fp, new RunId("run-oai"), 1, now, now.plusSeconds(1), + ProcessingStatus.READY_FOR_AI, + null, null, false, + "openai-compatible", + null, null, null, null, null, null, + null, null, null, null); + + repository.save(attempt); + + List saved = repository.findAllByFingerprint(fp); + assertThat(saved).hasSize(1); + assertThat(saved.get(0).aiProvider()).isEqualTo("openai-compatible"); + } + + /** + * A new attempt written with an active Claude provider must persist + * {@code "claude"} in {@code ai_provider}. + *

    + * The provider selection is simulated at the data level here; the actual + * Claude adapter is wired in a later step. + */ + @Test + void newAttemptsWriteClaudeProvider() { + schemaAdapter.initializeSchema(); + DocumentFingerprint fp = fingerprint("cc"); + insertDocumentRecord(fp); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + ProcessingAttempt attempt = new ProcessingAttempt( + fp, new RunId("run-claude"), 1, now, now.plusSeconds(1), + ProcessingStatus.READY_FOR_AI, + null, null, false, + "claude", + null, null, null, null, null, null, + null, null, null, null); + + repository.save(attempt); + + List saved = repository.findAllByFingerprint(fp); + assertThat(saved).hasSize(1); + assertThat(saved.get(0).aiProvider()).isEqualTo("claude"); + } + + // ------------------------------------------------------------------------- + // Read tests + // ------------------------------------------------------------------------- + + /** + * The repository must correctly return the persisted provider identifier + * when reading an attempt back from the database. + */ + @Test + void repositoryReadsProviderColumn() { + schemaAdapter.initializeSchema(); + DocumentFingerprint fp = fingerprint("dd"); + insertDocumentRecord(fp); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + repository.save(new ProcessingAttempt( + fp, new RunId("run-read"), 1, now, now.plusSeconds(2), + ProcessingStatus.FAILED_RETRYABLE, + "Timeout", "Connection timed out", true, + "openai-compatible", + null, null, null, null, null, null, + null, null, null, null)); + + List loaded = repository.findAllByFingerprint(fp); + assertThat(loaded).hasSize(1); + assertThat(loaded.get(0).aiProvider()) + .as("Repository must return the persisted ai_provider value") + .isEqualTo("openai-compatible"); + } + + /** + * Reading a database that was created without the {@code ai_provider} column + * (a pre-extension database) must succeed; the new field must be empty/null + * for historical attempts. + */ + @Test + void legacyDataReadingDoesNotFail() throws SQLException { + // Set up legacy schema with a row that has no ai_provider column + createLegacySchema(); + DocumentFingerprint fp = fingerprint("ee"); + insertLegacyDocumentRecord(fp); + insertLegacyAttemptRow(fp, "FAILED_RETRYABLE"); + + // Evolve schema — now ai_provider column exists but legacy rows have NULL + schemaAdapter.initializeSchema(); + + // Reading must not throw and must return null for ai_provider + List attempts = repository.findAllByFingerprint(fp); + assertThat(attempts).hasSize(1); + assertThat(attempts.get(0).aiProvider()) + .as("Legacy attempt (from before provider tracking) must have null aiProvider") + .isNull(); + // Other fields must still be readable + assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE); + } + + /** + * All existing attempt history tests must remain green: the repository + * handles null {@code ai_provider} values transparently without errors. + */ + @Test + void existingHistoryTestsRemainGreen() { + schemaAdapter.initializeSchema(); + DocumentFingerprint fp = fingerprint("ff"); + insertDocumentRecord(fp); + + Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Save attempt with null provider (as in legacy path or non-AI attempt) + ProcessingAttempt nullProviderAttempt = ProcessingAttempt.withoutAiFields( + fp, new RunId("run-legacy"), 1, + base, base.plusSeconds(1), + ProcessingStatus.FAILED_RETRYABLE, + "Err", "msg", true); + repository.save(nullProviderAttempt); + + // Save attempt with explicit provider + ProcessingAttempt withProvider = new ProcessingAttempt( + fp, new RunId("run-new"), 2, + base.plusSeconds(10), base.plusSeconds(11), + ProcessingStatus.READY_FOR_AI, + null, null, false, + "openai-compatible", + null, null, null, null, null, null, + null, null, null, null); + repository.save(withProvider); + + List all = repository.findAllByFingerprint(fp); + assertThat(all).hasSize(2); + assertThat(all.get(0).aiProvider()).isNull(); + assertThat(all.get(1).aiProvider()).isEqualTo("openai-compatible"); + // Ordering preserved + assertThat(all.get(0).attemptNumber()).isEqualTo(1); + assertThat(all.get(1).attemptNumber()).isEqualTo(2); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private boolean columnExists(String table, String column) throws SQLException { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getColumns(null, null, table, column)) { + return rs.next(); + } + } + } + + /** + * Creates the base tables that existed before the {@code ai_provider} column was added, + * without running the schema evolution that adds that column. + */ + private void createLegacySchema() throws SQLException { + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.execute("PRAGMA foreign_keys = ON"); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS document_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL, + last_known_source_locator TEXT NOT NULL, + last_known_source_file_name TEXT NOT NULL, + overall_status TEXT NOT NULL, + content_error_count INTEGER NOT NULL DEFAULT 0, + transient_error_count INTEGER NOT NULL DEFAULT 0, + last_failure_instant TEXT, + last_success_instant TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint) + )"""); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS processing_attempt ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL, + run_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + status TEXT NOT NULL, + failure_class TEXT, + failure_message TEXT, + retryable INTEGER NOT NULL DEFAULT 0, + model_name TEXT, + prompt_identifier TEXT, + processed_page_count INTEGER, + sent_character_count INTEGER, + ai_raw_response TEXT, + ai_reasoning TEXT, + resolved_date TEXT, + date_source TEXT, + validated_title TEXT, + final_target_file_name TEXT, + CONSTRAINT fk_processing_attempt_fingerprint + FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint), + CONSTRAINT uq_processing_attempt_fingerprint_number + UNIQUE (fingerprint, attempt_number) + )"""); + } + } + + private void insertLegacyDocumentRecord(DocumentFingerprint fp) throws SQLException { + 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(); + } + } + + private void insertLegacyAttemptRow(DocumentFingerprint fp, String status) throws SQLException { + try (Connection conn = DriverManager.getConnection(jdbcUrl); + PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO processing_attempt + (fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable) + VALUES (?, 'run-legacy', 1, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), ?, 1)""")) { + ps.setString(1, fp.sha256Hex()); + ps.setString(2, status); + ps.executeUpdate(); + } + } + + 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-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java index dbb0c88..86c7060 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java @@ -391,6 +391,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, runId, 1, startedAt, endedAt, ProcessingStatus.PROPOSAL_READY, null, null, false, + "openai-compatible", "gpt-4o", "prompt-v1.txt", 5, 1234, "{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}", @@ -434,6 +435,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, runId, 1, now, now.plusSeconds(5), ProcessingStatus.PROPOSAL_READY, null, null, false, + "openai-compatible", "claude-sonnet-4-6", "prompt-v2.txt", 3, 800, "{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}", @@ -531,6 +533,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "gpt-4o", "prompt-v1.txt", 2, 500, "{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}", "Found.", date, DateSource.AI_PROVIDED, "Rechnung", @@ -560,6 +563,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model-a", "prompt-v1.txt", 1, 100, "{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins", null @@ -577,6 +581,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model-b", "prompt-v2.txt", 2, 200, "{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei", null @@ -606,6 +611,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, runId, 1, now, now.plusSeconds(3), ProcessingStatus.SUCCESS, null, null, false, + null, "gpt-4", "prompt-v1.txt", 2, 600, "{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}", "Invoice.", @@ -637,6 +643,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "gpt-4", "prompt-v1.txt", 1, 200, "{}", "reason", LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED, @@ -667,6 +674,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model-a", "prompt-v1.txt", 3, 700, "{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null ); @@ -679,7 +687,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { ProcessingStatus.SUCCESS, null, null, false, null, null, null, null, null, null, - null, null, null, + null, null, null, null, "2026-02-10 - Bescheid.pdf" ); repository.save(successAttempt); @@ -742,6 +750,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model-x", "prompt-v1.txt", 1, 50, "{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel", null @@ -787,6 +796,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest { fingerprint, runId, 1, now, now.plusSeconds(5), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "gpt-4o", "prompt-v1.txt", 3, 750, fullRawResponse, diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java index 47186ae..7fcf252 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java @@ -119,7 +119,8 @@ class SqliteSchemaInitializationAdapterTest { "resolved_date", "date_source", "validated_title", - "final_target_file_name" + "final_target_file_name", + "ai_provider" ); } diff --git a/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties b/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties index 23d1253..38e959c 100644 --- a/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties +++ b/pdf-umbenenner-adapter-out/src/test/resources/missing-required.properties @@ -1,11 +1,12 @@ source.folder=/tmp/source target.folder=/tmp/target # sqlite.file is missing -api.baseUrl=https://api.example.com -api.model=gpt-4 -api.timeoutSeconds=30 +ai.provider.active=openai-compatible +ai.provider.openai-compatible.baseUrl=https://api.example.com +ai.provider.openai-compatible.model=gpt-4 +ai.provider.openai-compatible.timeoutSeconds=30 +ai.provider.openai-compatible.apiKey=test-api-key max.retries.transient=3 max.pages=100 max.text.characters=50000 prompt.template.file=/tmp/prompt.txt -api.key=test-api-key \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties b/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties index 9f9ec6d..91d643d 100644 --- a/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties +++ b/pdf-umbenenner-adapter-out/src/test/resources/no-api-key.properties @@ -1,10 +1,11 @@ source.folder=/tmp/source target.folder=/tmp/target sqlite.file=/tmp/db.sqlite -api.baseUrl=https://api.example.com -api.model=gpt-4 -api.timeoutSeconds=30 +ai.provider.active=openai-compatible +ai.provider.openai-compatible.baseUrl=https://api.example.com +ai.provider.openai-compatible.model=gpt-4 +ai.provider.openai-compatible.timeoutSeconds=30 max.retries.transient=3 max.pages=100 max.text.characters=50000 -prompt.template.file=/tmp/prompt.txt \ No newline at end of file +prompt.template.file=/tmp/prompt.txt diff --git a/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties b/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties index d6bef88..d1aa22d 100644 --- a/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties +++ b/pdf-umbenenner-adapter-out/src/test/resources/valid-config.properties @@ -1,9 +1,11 @@ source.folder=/tmp/source target.folder=/tmp/target sqlite.file=/tmp/db.sqlite -api.baseUrl=https://api.example.com -api.model=gpt-4 -api.timeoutSeconds=30 +ai.provider.active=openai-compatible +ai.provider.openai-compatible.baseUrl=https://api.example.com +ai.provider.openai-compatible.model=gpt-4 +ai.provider.openai-compatible.timeoutSeconds=30 +ai.provider.openai-compatible.apiKey=test-api-key-from-properties max.retries.transient=3 max.pages=100 max.text.characters=50000 @@ -11,4 +13,3 @@ prompt.template.file=/tmp/prompt.txt runtime.lock.file=/tmp/lock.lock log.directory=/tmp/logs log.level=DEBUG -api.key=test-api-key-from-properties \ No newline at end of file diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/AiProviderFamily.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/AiProviderFamily.java new file mode 100644 index 0000000..d3973e7 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/AiProviderFamily.java @@ -0,0 +1,59 @@ +package de.gecheckt.pdf.umbenenner.application.config.provider; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Supported AI provider families for the PDF renaming process. + *

    + * Each constant represents a distinct API protocol family. Exactly one provider family + * is active per application run, selected via the {@code ai.provider.active} configuration property. + *

    + * The {@link #getIdentifier()} method returns the string that must appear as the value of + * {@code ai.provider.active} to activate the corresponding provider family. + * Use {@link #fromIdentifier(String)} to resolve a configuration string to the enum constant. + */ +public enum AiProviderFamily { + + /** OpenAI-compatible Chat Completions API – usable with OpenAI itself and compatible third-party endpoints. */ + OPENAI_COMPATIBLE("openai-compatible"), + + /** Native Anthropic Messages API for Claude models. */ + CLAUDE("claude"); + + private final String identifier; + + AiProviderFamily(String identifier) { + this.identifier = identifier; + } + + /** + * Returns the configuration identifier string for this provider family. + *

    + * This value corresponds to valid values of the {@code ai.provider.active} property. + * + * @return the configuration identifier, never {@code null} + */ + public String getIdentifier() { + return identifier; + } + + /** + * Resolves a provider family from its configuration identifier string. + *

    + * The comparison is case-sensitive and matches the exact identifier strings + * defined by each constant (e.g., {@code "openai-compatible"}, {@code "claude"}). + * + * @param identifier the identifier as it appears in the {@code ai.provider.active} property; + * {@code null} returns an empty Optional + * @return the matching provider family, or {@link Optional#empty()} if not recognized + */ + public static Optional fromIdentifier(String identifier) { + if (identifier == null) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(f -> f.identifier.equals(identifier)) + .findFirst(); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/MultiProviderConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/MultiProviderConfiguration.java new file mode 100644 index 0000000..c6ad3fc --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/MultiProviderConfiguration.java @@ -0,0 +1,43 @@ +package de.gecheckt.pdf.umbenenner.application.config.provider; + +/** + * Immutable multi-provider configuration model. + *

    + * Represents the resolved configuration for both supported AI provider families + * together with the selection of the one provider family that is active for this + * application run. + * + *

    Invariants

    + *
      + *
    • Exactly one provider family is active per run.
    • + *
    • Required fields are enforced only for the active provider; the inactive + * provider's configuration may be incomplete.
    • + *
    • Validation of these invariants is performed by the corresponding validator + * in the adapter layer, not by this record itself.
    • + *
    + * + * @param activeProviderFamily the selected provider family for this run; {@code null} + * indicates that {@code ai.provider.active} was absent or + * held an unrecognised value – the validator will reject this + * @param openAiCompatibleConfig configuration for the OpenAI-compatible provider family + * @param claudeConfig configuration for the Anthropic Claude provider family + */ +public record MultiProviderConfiguration( + AiProviderFamily activeProviderFamily, + ProviderConfiguration openAiCompatibleConfig, + ProviderConfiguration claudeConfig) { + + /** + * Returns the {@link ProviderConfiguration} for the currently active provider family. + * + * @return the active provider's configuration, never {@code null} when + * {@link #activeProviderFamily()} is not {@code null} + * @throws NullPointerException if {@code activeProviderFamily} is {@code null} + */ + public ProviderConfiguration activeProviderConfiguration() { + return switch (activeProviderFamily) { + case OPENAI_COMPATIBLE -> openAiCompatibleConfig; + case CLAUDE -> claudeConfig; + }; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java new file mode 100644 index 0000000..885d350 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/provider/ProviderConfiguration.java @@ -0,0 +1,34 @@ +package de.gecheckt.pdf.umbenenner.application.config.provider; + +/** + * Immutable configuration for a single AI provider family. + *

    + * Holds all parameters needed to connect to and authenticate with one AI provider endpoint. + * Instances are created by the configuration parser in the adapter layer; validation + * of required fields is performed by the corresponding validator. + * + *

    Field semantics

    + *
      + *
    • {@code model} – the AI model name; required for the active provider, may be {@code null} + * for the inactive provider.
    • + *
    • {@code timeoutSeconds} – HTTP connection/read timeout in seconds; must be positive for + * the active provider. {@code 0} indicates the value was not configured.
    • + *
    • {@code baseUrl} – the base URL of the API endpoint. For the Anthropic Claude family a + * default of {@code https://api.anthropic.com} is applied by the parser when the property + * is absent; for the OpenAI-compatible family it is required and may not be {@code null}.
    • + *
    • {@code apiKey} – the resolved API key after environment-variable precedence has been + * applied; may be blank for the inactive provider, must not be blank for the active provider.
    • + *
    + * + * @param model the AI model name; {@code null} when not configured + * @param timeoutSeconds HTTP timeout in seconds; {@code 0} when not configured + * @param baseUrl the base URL of the API endpoint; {@code null} when not configured + * (only applicable to providers without a built-in default) + * @param apiKey the resolved API key; blank when not configured + */ +public record ProviderConfiguration( + String model, + int timeoutSeconds, + String baseUrl, + String apiKey) { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java index dac24f2..8ed46fb 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/config/startup/StartConfiguration.java @@ -1,16 +1,24 @@ package de.gecheckt.pdf.umbenenner.application.config.startup; -import java.net.URI; import java.nio.file.Path; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; + /** * Typed immutable configuration model for PDF Umbenenner startup parameters. *

    * Contains all technical infrastructure and runtime configuration parameters * loaded and validated at bootstrap time. This is a complete configuration model - * for the entire application startup, including paths, API settings, persistence, + * for the entire application startup, including paths, AI provider selection, persistence, * and operational parameters. * + *

    AI provider configuration

    + *

    + * The {@link MultiProviderConfiguration} encapsulates the active provider selection + * together with the per-provider connection parameters for all supported provider families. + * Exactly one provider family is active per run; the selection is driven by the + * {@code ai.provider.active} configuration property. + * *

    AI content sensitivity ({@code log.ai.sensitive})

    *

    * The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated @@ -25,9 +33,7 @@ public record StartConfiguration( Path sourceFolder, Path targetFolder, Path sqliteFile, - URI apiBaseUrl, - String apiModel, - int apiTimeoutSeconds, + MultiProviderConfiguration multiProviderConfiguration, int maxRetriesTransient, int maxPages, int maxTextCharacters, @@ -35,7 +41,6 @@ public record StartConfiguration( Path runtimeLockFile, Path logDirectory, String logLevel, - String apiKey, /** * Whether sensitive AI content (raw response, reasoning) may be written to log files. diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java index 8b3628a..cc6d0bf 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java @@ -42,6 +42,10 @@ import java.util.Objects; * successful or skip attempts. *

  • {@link #retryable()} — {@code true} if the failure is considered retryable in a * later run; {@code false} for final failures, successes, and skip attempts.
  • + *
  • {@link #aiProvider()} — opaque identifier of the AI provider that was active + * during this attempt (e.g. {@code "openai-compatible"} or {@code "claude"}); + * {@code null} for attempts that did not involve an AI call (skip, pre-check + * failure) or for historical attempts recorded before this field was introduced.
  • *
  • {@link #modelName()} — the AI model name used in this attempt; {@code null} if * no AI call was made (e.g. pre-check failures or skip attempts).
  • *
  • {@link #promptIdentifier()} — stable identifier of the prompt template used; @@ -74,6 +78,7 @@ import java.util.Objects; * @param failureClass failure classification, or {@code null} for non-failure statuses * @param failureMessage failure description, or {@code null} for non-failure statuses * @param retryable whether this failure should be retried in a later run + * @param aiProvider opaque AI provider identifier for this attempt, or {@code null} * @param modelName AI model name, or {@code null} if no AI call was made * @param promptIdentifier prompt identifier, or {@code null} if no AI call was made * @param processedPageCount number of PDF pages processed, or {@code null} @@ -97,6 +102,7 @@ public record ProcessingAttempt( String failureMessage, boolean retryable, // AI traceability fields (null for non-AI attempts) + String aiProvider, String modelName, String promptIdentifier, Integer processedPageCount, @@ -131,7 +137,8 @@ public record ProcessingAttempt( * Creates a {@link ProcessingAttempt} with no AI traceability fields set. *

    * Convenience factory for pre-check failures, skip events, and any attempt - * that does not involve an AI call. + * that does not involve an AI call. The {@link #aiProvider()} field is set + * to {@code null}. * * @param fingerprint document identity; must not be null * @param runId batch run identifier; must not be null @@ -157,6 +164,6 @@ public record ProcessingAttempt( return new ProcessingAttempt( fingerprint, runId, attemptNumber, startedAt, endedAt, status, failureClass, failureMessage, retryable, - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 77a2894..ca4f5b6 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -154,15 +154,22 @@ public class DocumentProcessingCoordinator { private final TargetFileCopyPort targetFileCopyPort; private final ProcessingLogger logger; private final int maxRetriesTransient; + private final String activeProviderIdentifier; /** - * Creates the document processing coordinator with all required ports, logger, and - * the transient retry limit. + * Creates the document processing coordinator with all required ports, logger, + * the transient retry limit, and the active AI provider identifier. *

    * {@code maxRetriesTransient} is the maximum number of historised transient error attempts * per fingerprint before the document is finalised to * {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to * reach this value finalises the document. Must be >= 1. + *

    + * {@code activeProviderIdentifier} is the opaque string identifier of the AI provider + * that is active for this run (e.g. {@code "openai-compatible"} or {@code "claude"}). + * It is written to the attempt history for every attempt that involves an AI call, + * enabling provider-level traceability per attempt without introducing + * provider-specific logic in the application layer. * * @param documentRecordRepository port for reading and writing the document master record; * must not be null @@ -176,8 +183,11 @@ public class DocumentProcessingCoordinator { * @param logger for processing-related logging; must not be null * @param maxRetriesTransient maximum number of historised transient error attempts * before finalisation; must be >= 1 + * @param activeProviderIdentifier opaque identifier of the active AI provider for this run; + * must not be null or blank * @throws NullPointerException if any object parameter is null - * @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1 + * @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1, or + * if {@code activeProviderIdentifier} is blank */ public DocumentProcessingCoordinator( DocumentRecordRepository documentRecordRepository, @@ -186,11 +196,16 @@ public class DocumentProcessingCoordinator { TargetFolderPort targetFolderPort, TargetFileCopyPort targetFileCopyPort, ProcessingLogger logger, - int maxRetriesTransient) { + int maxRetriesTransient, + String activeProviderIdentifier) { if (maxRetriesTransient < 1) { throw new IllegalArgumentException( "maxRetriesTransient must be >= 1, got: " + maxRetriesTransient); } + Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null"); + if (activeProviderIdentifier.isBlank()) { + throw new IllegalArgumentException("activeProviderIdentifier must not be blank"); + } this.documentRecordRepository = Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null"); this.processingAttemptRepository = @@ -203,6 +218,7 @@ public class DocumentProcessingCoordinator { Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null"); this.logger = Objects.requireNonNull(logger, "logger must not be null"); this.maxRetriesTransient = maxRetriesTransient; + this.activeProviderIdentifier = activeProviderIdentifier; } /** @@ -503,7 +519,7 @@ public class DocumentProcessingCoordinator { ProcessingAttempt successAttempt = new ProcessingAttempt( fingerprint, context.runId(), attemptNumber, attemptStart, now, ProcessingStatus.SUCCESS, null, null, false, - null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, resolvedFilename); DocumentRecord successRecord = buildSuccessRecord( @@ -951,6 +967,7 @@ public class DocumentProcessingCoordinator { yield new ProcessingAttempt( fingerprint, context.runId(), attemptNumber, startedAt, endedAt, outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), + activeProviderIdentifier, ctx.modelName(), ctx.promptIdentifier(), ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.aiRawResponse(), @@ -964,6 +981,7 @@ public class DocumentProcessingCoordinator { yield new ProcessingAttempt( fingerprint, context.runId(), attemptNumber, startedAt, endedAt, outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), + activeProviderIdentifier, ctx.modelName(), ctx.promptIdentifier(), ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.aiRawResponse(), @@ -976,6 +994,7 @@ public class DocumentProcessingCoordinator { yield new ProcessingAttempt( fingerprint, context.runId(), attemptNumber, startedAt, endedAt, outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), + activeProviderIdentifier, ctx.modelName(), ctx.promptIdentifier(), ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.aiRawResponse(), diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java index 789ccb2..55a8b3d 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java @@ -90,7 +90,7 @@ class DocumentProcessingCoordinatorTest { unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo); processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); candidate = new SourceDocumentCandidate( "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); @@ -250,7 +250,8 @@ class DocumentProcessingCoordinatorTest { // With maxRetriesTransient=1, the very first transient error finalises the document DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1, + "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = new TechnicalDocumentError( @@ -668,7 +669,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null)); DocumentProcessingOutcome outcome = new PreCheckPassed( candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); @@ -686,7 +687,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -705,7 +706,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)); recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckFailed( @@ -724,7 +725,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = new PreCheckPassed( candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); @@ -742,7 +743,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); unitOfWorkPort.failOnExecute = true; DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -761,7 +762,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); DocumentProcessingOutcome outcome = new PreCheckPassed( @@ -780,7 +781,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturingLogger = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); unitOfWorkPort.failOnExecute = true; @@ -848,6 +849,7 @@ class DocumentProcessingCoordinatorTest { ProcessingAttempt badProposal = new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model", "prompt", 1, 100, "{}", "reason", null, DateSource.AI_PROVIDED, "Rechnung", null); attemptRepo.savedAttempts.add(badProposal); @@ -871,7 +873,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); boolean result = coordinatorWithFailingFolder.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -893,7 +895,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); boolean result = coordinatorWithFailingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -915,6 +917,7 @@ class DocumentProcessingCoordinatorTest { ProcessingAttempt badProposal = new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model", "prompt", 1, 100, "{}", "reason", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "A".repeat(21), null); @@ -941,6 +944,7 @@ class DocumentProcessingCoordinatorTest { ProcessingAttempt badProposal = new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model", "prompt", 1, 100, "{}", "reason", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung-2026", null); @@ -980,7 +984,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); boolean result = coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> { @@ -1014,7 +1018,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); boolean result = coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1044,7 +1048,8 @@ class DocumentProcessingCoordinatorTest { CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, - new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1); + new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1, + "openai-compatible"); boolean result = coordinatorWith1Retry.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1079,7 +1084,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1105,7 +1110,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, - 1 /* maxRetriesTransient=1 → immediately final */); + 1 /* maxRetriesTransient=1 → immediately final */, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1128,7 +1133,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCountingCopy.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1197,7 +1202,8 @@ class DocumentProcessingCoordinatorTest { // maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2, + "openai-compatible"); DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null); // Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1 @@ -1233,6 +1239,7 @@ class DocumentProcessingCoordinatorTest { return new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + "openai-compatible", "gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null); } @@ -1495,7 +1502,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new DocumentTerminalSuccess( buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()))); @@ -1516,7 +1523,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new DocumentTerminalFinalFailure( buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)))); @@ -1537,7 +1544,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); recordRepo.setLookupResult(new DocumentUnknown()); coordinatorWithCapturing.process(candidate, fingerprint, @@ -1560,7 +1567,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); // Existing record already has one content error — second content error finalises recordRepo.setLookupResult(new DocumentKnownProcessable( buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0)))); @@ -1596,7 +1603,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1612,6 +1619,7 @@ class DocumentProcessingCoordinatorTest { ProcessingAttempt badProposal = new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + null, "model", "prompt", 1, 100, "{}", "reason", null, DateSource.AI_PROVIDED, "Rechnung", null); attemptRepo.savedAttempts.add(badProposal); @@ -1620,7 +1628,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1639,7 +1647,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1658,7 +1666,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1680,7 +1688,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1702,7 +1710,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), bothFail, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, c -> null); @@ -1723,7 +1731,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1843,7 +1851,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome( candidate, fingerprint, context, attemptStart, @@ -1873,7 +1881,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); @@ -1897,7 +1905,7 @@ class DocumentProcessingCoordinatorTest { DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWorkPort, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, - DEFAULT_MAX_RETRIES_TRANSIENT); + DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible"); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java index e70ffca..040912a 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java @@ -356,6 +356,7 @@ class TargetFilenameBuildingServiceTest { Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, + "openai-compatible", "gpt-4", "prompt-v1.txt", 1, 100, "{}", "reasoning text", date, DateSource.AI_PROVIDED, title, diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index 590bb0b..205668f 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -469,7 +469,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator( new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), - new NoOpProcessingLogger(), 3) { + new NoOpProcessingLogger(), 3, "openai-compatible") { @Override public boolean processDeferredOutcome( de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, @@ -517,7 +517,7 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator( new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), - new NoOpProcessingLogger(), 3) { + new NoOpProcessingLogger(), 3, "openai-compatible") { private int callCount = 0; @Override @@ -760,7 +760,8 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, + "openai-compatible"); // Fingerprint port returns the pre-defined fingerprint for this candidate FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint); @@ -807,7 +808,8 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, + "openai-compatible"); FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint); @@ -860,7 +862,8 @@ class BatchRunProcessingUseCaseTest { DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( recordRepo, attemptRepo, unitOfWork, - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, + "openai-compatible"); FingerprintPort perCandidateFingerprintPort = candidate -> { if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp); @@ -1152,7 +1155,8 @@ class BatchRunProcessingUseCaseTest { private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator { NoOpDocumentProcessingCoordinator() { super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, + "openai-compatible"); } } @@ -1164,7 +1168,8 @@ class BatchRunProcessingUseCaseTest { TrackingDocumentProcessingCoordinator() { super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), - new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); + new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3, + "openai-compatible"); } @Override diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java new file mode 100644 index 0000000..1704a7a --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelector.java @@ -0,0 +1,62 @@ +package de.gecheckt.pdf.umbenenner.bootstrap; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.adapter.out.ai.AnthropicClaudeHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; + +/** + * Selects and instantiates the active {@link AiInvocationPort} implementation + * based on the configured provider family. + *

    + * This component lives in the bootstrap layer and is the single point where + * the active provider family is mapped to its corresponding adapter implementation. + * Exactly one provider is selected per application run; the selection is driven + * by the value of {@code ai.provider.active}. + * + *

    Registered providers

    + *
      + *
    • {@link AiProviderFamily#OPENAI_COMPATIBLE} — {@link OpenAiHttpAdapter}
    • + *
    • {@link AiProviderFamily#CLAUDE} — {@link AnthropicClaudeHttpAdapter}
    • + *
    + * + *

    Hard start failure

    + *

    + * If the requested provider family has no registered implementation, an + * {@link InvalidStartConfigurationException} is thrown immediately, which the + * bootstrap runner maps to exit code 1. + */ +public class AiProviderSelector { + + /** + * Selects and constructs the {@link AiInvocationPort} implementation for the given + * provider family using the supplied provider configuration. + * + * @param family the active provider family; must not be {@code null} + * @param config the configuration for the active provider; must not be {@code null} + * @return the constructed adapter instance; never {@code null} + * @throws InvalidStartConfigurationException if no implementation is registered + * for the requested provider family + */ + public AiInvocationPort select(AiProviderFamily family, ProviderConfiguration config) { + Objects.requireNonNull(family, "provider family must not be null"); + Objects.requireNonNull(config, "provider configuration must not be null"); + + if (family == AiProviderFamily.OPENAI_COMPATIBLE) { + return new OpenAiHttpAdapter(config); + } + + if (family == AiProviderFamily.CLAUDE) { + return new AnthropicClaudeHttpAdapter(config); + } + + throw new InvalidStartConfigurationException( + "No AI adapter implementation registered for provider family: " + + family.getIdentifier() + + ". Supported in the current build: openai-compatible, claude"); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 431b161..449e040 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -9,11 +9,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; -import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException; +import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter; @@ -27,6 +27,8 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter; import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; @@ -68,6 +70,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; * configuration is handed to the use case factory which extracts the minimal runtime * configuration for the application layer. * + *

    Active AI provider

    + *

    + * The active AI provider family is determined from the configuration and logged at run start. + * The {@link AiProviderSelector} in the bootstrap layer selects the appropriate + * {@link AiInvocationPort} implementation. Exactly one provider is active per run. + * *

    Exit code semantics

    *
      *
    • {@code 0}: Batch run executed successfully; individual document failures do not @@ -82,10 +90,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; *

      * The production constructor wires the following key adapters: *

        - *
      • {@link PropertiesConfigurationPortAdapter} — loads configuration from properties and environment.
      • + *
      • {@link PropertiesConfigurationPortAdapter} — loads configuration from the multi-provider + * properties schema and environment.
      • + *
      • {@link AiProviderSelector} — selects the active {@link AiInvocationPort} implementation + * based on {@code ai.provider.active}.
      • *
      • {@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.
      • - *
      • {@link SqliteSchemaInitializationAdapter} — initializes SQLite schema (including target-copy - * schema evolution) at startup.
      • + *
      • {@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.
      • *
      • {@link Sha256FingerprintAdapter} — provides content-based document identification.
      • *
      • {@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.
      • *
      • {@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.
      • @@ -103,6 +113,7 @@ public class BootstrapRunner { private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class); + private final MigrationStep migrationStep; private final ConfigurationPortFactory configPortFactory; private final RunLockPortFactory runLockPortFactory; private final ValidatorFactory validatorFactory; @@ -110,6 +121,19 @@ public class BootstrapRunner { private final UseCaseFactory useCaseFactory; private final CommandFactory commandFactory; + /** + * Functional interface encapsulating the legacy configuration migration step. + *

        + * The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy} + * on the active configuration file before any configuration is loaded. In tests, a + * no-op lambda is injected so that migration does not interfere with mock configuration ports. + */ + @FunctionalInterface + public interface MigrationStep { + /** Runs the legacy configuration migration if the configuration file is in legacy form. */ + void runIfNeeded(); + } + /** * Functional interface for creating a ConfigurationPort. */ @@ -175,12 +199,12 @@ public class BootstrapRunner { * Wires the processing pipeline with the following adapters: *

          *
        • {@link PropertiesConfigurationPortAdapter} for configuration loading.
        • + *
        • {@link AiProviderSelector} for selecting the active AI provider implementation.
        • *
        • {@link FilesystemRunLockPortAdapter} for exclusive run locking.
        • *
        • {@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.
        • *
        • {@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.
        • *
        • {@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.
        • - *
        • {@link SqliteSchemaInitializationAdapter} for SQLite schema DDL and target-copy schema - * evolution at startup.
        • + *
        • {@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.
        • *
        • {@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.
        • *
        • {@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.
        • *
        • {@link SqliteUnitOfWorkAdapter} for atomic persistence operations.
        • @@ -199,6 +223,8 @@ public class BootstrapRunner { * begins. Failure during initialisation aborts the run with exit code 1. */ public BootstrapRunner() { + this.migrationStep = () -> new LegacyConfigurationMigrator() + .migrateIfLegacy(Paths.get("config/application.properties")); this.configPortFactory = PropertiesConfigurationPortAdapter::new; this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.validatorFactory = StartConfigurationValidator::new; @@ -206,7 +232,13 @@ public class BootstrapRunner { this.useCaseFactory = (startConfig, lock) -> { // Extract runtime configuration from startup configuration AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); - RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); + RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( + startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); + + // Select the active AI provider adapter + AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily(); + ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration(); + AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig); String jdbcUrl = buildJdbcUrl(startConfig); FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); @@ -216,17 +248,18 @@ public class BootstrapRunner { new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl); - // Wire coordinators logger with AI content sensitivity setting - ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class, aiContentSensitivity); + // Wire coordinator logger with AI content sensitivity setting + ProcessingLogger coordinatorLogger = new Log4jProcessingLogger( + DocumentProcessingCoordinator.class, aiContentSensitivity); TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder()); DocumentProcessingCoordinator documentProcessingCoordinator = new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger, - startConfig.maxRetriesTransient()); + startConfig.maxRetriesTransient(), + activeFamily.getIdentifier()); // Wire AI naming pipeline - AiInvocationPort aiInvocationPort = new OpenAiHttpAdapter(startConfig); PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); ClockPort clockPort = new SystemClockAdapter(); AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); @@ -234,11 +267,12 @@ public class BootstrapRunner { aiInvocationPort, promptPort, aiResponseValidator, - startConfig.apiModel(), + providerConfig.model(), startConfig.maxTextCharacters()); // Wire use case logger with AI content sensitivity setting - ProcessingLogger useCaseLogger = new Log4jProcessingLogger(DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); + ProcessingLogger useCaseLogger = new Log4jProcessingLogger( + DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); return new DefaultBatchRunProcessingUseCase( runtimeConfig, lock, @@ -254,6 +288,9 @@ public class BootstrapRunner { /** * Creates the BootstrapRunner with custom factories for testing. + *

          + * The migration step is set to a no-op; tests that need to exercise the migration + * path use the full seven-parameter constructor. * * @param configPortFactory factory for creating ConfigurationPort instances * @param runLockPortFactory factory for creating RunLockPort instances @@ -268,6 +305,32 @@ public class BootstrapRunner { SchemaInitializationPortFactory schemaInitPortFactory, UseCaseFactory useCaseFactory, CommandFactory commandFactory) { + this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ }, + configPortFactory, runLockPortFactory, validatorFactory, + schemaInitPortFactory, useCaseFactory, commandFactory); + } + + /** + * Creates the BootstrapRunner with all factories including an explicit migration step. + *

          + * Use this constructor in tests that need to exercise the full migration-then-load path. + * + * @param migrationStep the legacy configuration migration step to run before loading + * @param configPortFactory factory for creating ConfigurationPort instances + * @param runLockPortFactory factory for creating RunLockPort instances + * @param validatorFactory factory for creating StartConfigurationValidator instances + * @param schemaInitPortFactory factory for creating PersistenceSchemaInitializationPort instances + * @param useCaseFactory factory for creating BatchRunProcessingUseCase instances + * @param commandFactory factory for creating SchedulerBatchCommand instances + */ + public BootstrapRunner(MigrationStep migrationStep, + ConfigurationPortFactory configPortFactory, + RunLockPortFactory runLockPortFactory, + ValidatorFactory validatorFactory, + SchemaInitializationPortFactory schemaInitPortFactory, + UseCaseFactory useCaseFactory, + CommandFactory commandFactory) { + this.migrationStep = migrationStep; this.configPortFactory = configPortFactory; this.runLockPortFactory = runLockPortFactory; this.validatorFactory = validatorFactory; @@ -299,6 +362,7 @@ public class BootstrapRunner { LOG.info("Bootstrap flow started."); try { // Bootstrap Phase: prepare configuration and persistence + migrateConfigurationIfNeeded(); StartConfiguration config = loadAndValidateConfiguration(); initializeSchema(config); // Execution Phase: run batch processing @@ -318,6 +382,20 @@ public class BootstrapRunner { } } + /** + * Runs the legacy configuration migration step exactly once before configuration loading. + *

          + * If the configuration file is in the legacy flat-key format, it is migrated in-place to the + * multi-provider schema before the normal configuration loading path is entered. If the file + * is already in the current schema, this method returns immediately without any I/O side effect. + *

          + * A migration failure is a hard startup error and propagates as a + * {@link ConfigurationLoadingException}. + */ + private void migrateConfigurationIfNeeded() { + migrationStep.runIfNeeded(); + } + /** * Loads configuration via {@link ConfigurationPort} and validates it via * {@link StartConfigurationValidator}. @@ -329,13 +407,17 @@ public class BootstrapRunner { * creatable (validator attempts {@code Files.createDirectories} if absent; * failure here is a hard startup error). *

        • {@code sqlite.file}: parent directory must exist.
        • - *
        • All numeric and URI constraints.
        • + *
        • All numeric and path constraints.
        • *
        + *

        + * After successful validation, the active AI provider identifier is logged at INFO level. */ private StartConfiguration loadAndValidateConfiguration() { ConfigurationPort configPort = configPortFactory.create(); StartConfiguration config = configPort.loadConfiguration(); validatorFactory.create().validate(config); + LOG.info("Active AI provider: {}", + config.multiProviderConfiguration().activeProviderFamily().getIdentifier()); return config; } diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java new file mode 100644 index 0000000..73e88fe --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/AiProviderSelectorTest.java @@ -0,0 +1,134 @@ +package de.gecheckt.pdf.umbenenner.bootstrap; + +import de.gecheckt.pdf.umbenenner.adapter.out.ai.AnthropicClaudeHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link AiProviderSelector}. + *

        + * Covers selection of the OpenAI-compatible adapter, selection of the Claude adapter, + * hard failure for unregistered provider families, and null-safety. + */ +@ExtendWith(MockitoExtension.class) +class AiProviderSelectorTest { + + private final AiProviderSelector selector = new AiProviderSelector(); + + private static ProviderConfiguration validOpenAiConfig() { + return new ProviderConfiguration("gpt-4", 30, "https://api.example.com", "test-key"); + } + + private static ProviderConfiguration validClaudeConfig() { + return new ProviderConfiguration( + "claude-3-5-sonnet-20241022", 60, "https://api.anthropic.com", "sk-ant-key"); + } + + // ========================================================================= + // Mandatory test case: bootstrapWiresOpenAiCompatibleAdapterWhenActive + // ========================================================================= + + /** + * When the active provider family is OPENAI_COMPATIBLE, the selector must return + * an {@link OpenAiHttpAdapter} instance. + */ + @Test + void bootstrapWiresOpenAiCompatibleAdapterWhenActive() { + AiInvocationPort port = selector.select(AiProviderFamily.OPENAI_COMPATIBLE, validOpenAiConfig()); + + assertNotNull(port, "Selector must return a non-null AiInvocationPort"); + assertInstanceOf(OpenAiHttpAdapter.class, port, + "OPENAI_COMPATIBLE must be wired to OpenAiHttpAdapter"); + } + + // ========================================================================= + // Mandatory test case: bootstrapSelectsClaudeWhenActive (AP-005) + // ========================================================================= + + /** + * When the active provider family is CLAUDE, the selector must return an + * {@link AnthropicClaudeHttpAdapter} instance. + */ + @Test + void bootstrapSelectsClaudeWhenActive() { + AiInvocationPort port = selector.select(AiProviderFamily.CLAUDE, validClaudeConfig()); + + assertNotNull(port, "Selector must return a non-null AiInvocationPort for Claude"); + assertInstanceOf(AnthropicClaudeHttpAdapter.class, port, + "CLAUDE must be wired to AnthropicClaudeHttpAdapter"); + } + + // ========================================================================= + // Mandatory test case: bootstrapFailsHardWhenActiveProviderUnknown + // ========================================================================= + + /** + * A null provider family must result in a NullPointerException. + * This guards against uninitialised / null active-provider state that + * should be caught by the validator before reaching the selector. + */ + @Test + void bootstrapFailsHardWhenActiveProviderUnknown() { + assertThrows(NullPointerException.class, + () -> selector.select(null, validOpenAiConfig()), + "Null provider family must throw NullPointerException"); + } + + // ========================================================================= + // Mandatory test case: bootstrapFailsHardWhenSelectedProviderHasNoImplementation + // ========================================================================= + + /** + * A provider family with no registered adapter implementation must throw + * {@link InvalidStartConfigurationException} immediately, preventing the + * application from starting. + *

        + * Both known families (OPENAI_COMPATIBLE and CLAUDE) are now registered. + * This test uses a Mockito mock of {@link AiProviderFamily} to represent a + * hypothetical future or unknown provider, confirming that the selector's + * fallback guard remains in place for any unregistered family. + */ + @Test + void bootstrapFailsHardWhenSelectedProviderHasNoImplementation() { + // Create a mock AiProviderFamily that does not equal any registered constant + AiProviderFamily unknownFamily = mock(AiProviderFamily.class); + when(unknownFamily.getIdentifier()).thenReturn("unknown-future-provider"); + + ProviderConfiguration anyConfig = new ProviderConfiguration( + "some-model", 30, "https://unknown.example.com", "some-key"); + + InvalidStartConfigurationException ex = assertThrows( + InvalidStartConfigurationException.class, + () -> selector.select(unknownFamily, anyConfig), + "A provider family with no registered adapter must throw InvalidStartConfigurationException"); + + assertTrue(ex.getMessage().contains("unknown-future-provider") + || ex.getMessage().toLowerCase().contains("no ai adapter"), + "Error message must reference the unregistered provider or indicate missing registration"); + } + + // ========================================================================= + // Additional safety: null ProviderConfiguration + // ========================================================================= + + @Test + void selectThrowsWhenProviderConfigurationIsNull() { + assertThrows(NullPointerException.class, + () -> selector.select(AiProviderFamily.OPENAI_COMPATIBLE, null), + "Null ProviderConfiguration must throw NullPointerException"); + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java index 567a29a..b3055cb 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java @@ -4,6 +4,9 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; @@ -17,10 +20,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -51,9 +52,7 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("target")), Files.createFile(tempDir.resolve("db.sqlite")), - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -61,7 +60,6 @@ class BootstrapRunnerEdgeCasesTest { null, // null runtimeLockFile tempDir.resolve("logs"), "INFO", - "test-key", false ); @@ -101,14 +99,12 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("target")), sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, 3, 100, 50000, + validMultiProviderConfig(), + 3, 100, 50000, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-key", false ); @@ -128,14 +124,12 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("target")), sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, 3, 100, 50000, + validMultiProviderConfig(), + 3, 100, 50000, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-key", false ); @@ -157,13 +151,12 @@ class BootstrapRunnerEdgeCasesTest { Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("target")), Files.createFile(tempDir.resolve("db.sqlite")), - URI.create("https://api.example.com"), - "gpt-4", 30, 3, 100, 50000, + validMultiProviderConfig(), + 3, 100, 50000, Files.createFile(tempDir.resolve("prompt.txt")), tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-key", false ); @@ -226,9 +219,9 @@ class BootstrapRunnerEdgeCasesTest { Path dbFile = Files.createFile(tempDir.resolve("db.sqlite")); Path promptFile = Files.createFile(tempDir.resolve("prompt.txt")); return new StartConfiguration(sourceDir, targetDir, dbFile, - URI.create("https://api.example.com"), "gpt-4", 30, 3, 100, 50000, + validMultiProviderConfig(), 3, 100, 50000, promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"), - "INFO", "key", false); + "INFO", false); } catch (Exception e) { throw new RuntimeException(e); } @@ -342,9 +335,19 @@ class BootstrapRunnerEdgeCasesTest { "logAiSensitive=true must resolve to LOG_SENSITIVE_CONTENT"); } - // ========================================================================= + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MultiProviderConfiguration validMultiProviderConfig() { + ProviderConfiguration openAiConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-api-key"); + return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null); + } + + // ------------------------------------------------------------------------- // Mocks - // ========================================================================= + // ------------------------------------------------------------------------- private static class MockConfigurationPort implements ConfigurationPort { private final Path tempDir; @@ -373,13 +376,16 @@ class BootstrapRunnerEdgeCasesTest { Files.createFile(promptTemplateFile); } + ProviderConfiguration openAiConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-api-key"); + MultiProviderConfiguration multiConfig = new MultiProviderConfiguration( + AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null); + return new StartConfiguration( sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + multiConfig, 3, 100, 50000, @@ -387,7 +393,6 @@ class BootstrapRunnerEdgeCasesTest { tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-api-key", false ); } catch (Exception e) { diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java index 790f339..969e015 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java @@ -4,6 +4,11 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException; +import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator; +import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; @@ -13,13 +18,21 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Configuration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -176,9 +189,7 @@ class BootstrapRunnerTest { sourceDir, targetDir, dbFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 3, 100, 50000, @@ -186,7 +197,6 @@ class BootstrapRunnerTest { Paths.get(""), // empty – simulates unconfigured runtime.lock.file tempDir.resolve("logs"), "INFO", - "test-key", false ); @@ -262,9 +272,7 @@ class BootstrapRunnerTest { sourceDir, targetDir, dbFile, - java.net.URI.create("https://api.example.com"), - "gpt-4", - 30, + validMultiProviderConfig(), 0, // max.retries.transient = 0 is invalid (must be >= 1) 100, 50000, @@ -272,7 +280,6 @@ class BootstrapRunnerTest { tempDir.resolve("lock-mrt.lock"), null, "INFO", - "test-key", false ); @@ -346,6 +353,121 @@ class BootstrapRunnerTest { assertEquals(1, exitCode, "Schema initialization failure should return exit code 1"); } + // ========================================================================= + // Mandatory test case: activeProviderIsLoggedAtRunStart + // ========================================================================= + + /** + * The active AI provider identifier must be logged at INFO level during the bootstrap phase, + * after configuration is loaded and validated but before batch processing begins. + */ + @Test + void activeProviderIsLoggedAtRunStart() throws Exception { + ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); + BootstrapRunner runner = new BootstrapRunner( + () -> mockConfigPort, + lockFile -> new MockRunLockPort(), + StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), + (config, lock) -> new MockRunBatchProcessingUseCase(true), + SchedulerBatchCommand::new + ); + + List capturedMessages = new ArrayList<>(); + String appenderName = "TestCapture-" + UUID.randomUUID(); + LoggerContext ctx = (LoggerContext) LogManager.getContext(false); + Configuration cfg = ctx.getConfiguration(); + AbstractAppender captureAppender = new AbstractAppender(appenderName, null, null, false) { + @Override + public void append(LogEvent event) { + capturedMessages.add(event.getMessage().getFormattedMessage()); + } + }; + captureAppender.start(); + cfg.addAppender(captureAppender); + cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null); + ctx.updateLoggers(); + try { + runner.run(); + } finally { + cfg.getRootLogger().removeAppender(appenderName); + ctx.updateLoggers(); + captureAppender.stop(); + } + + assertTrue(capturedMessages.stream().anyMatch(m -> m.contains("openai-compatible")), + "Active AI provider identifier must be logged at run start"); + } + + // ========================================================================= + // Mandatory test case: legacyFileEndToEndStillRuns + // ========================================================================= + + /** + * End-to-end test verifying that a legacy flat-key configuration file is automatically + * migrated to the multi-provider schema and that the application run completes successfully + * after migration. + *

        + * Covers the full migration path: detection, backup creation, in-place rewrite, + * re-validation, and subsequent successful configuration load. + */ + @Test + void legacyFileEndToEndStillRuns(@TempDir Path workDir) throws Exception { + Path sourceDir = Files.createDirectories(workDir.resolve("source")); + Path targetDir = Files.createDirectories(workDir.resolve("target")); + Path dbParentDir = Files.createDirectories(workDir.resolve("data")); + Path promptDir = Files.createDirectories(workDir.resolve("config/prompts")); + Path promptFile = Files.createFile(promptDir.resolve("template.txt")); + Files.writeString(promptFile, "Test prompt template."); + + Path configFile = workDir.resolve("application.properties"); + String legacyConfig = String.format( + "source.folder=%s%n" + + "target.folder=%s%n" + + "sqlite.file=%s%n" + + "api.baseUrl=https://api.example.com%n" + + "api.model=gpt-4%n" + + "api.timeoutSeconds=30%n" + + "api.key=test-legacy-key%n" + + "max.retries.transient=3%n" + + "max.pages=10%n" + + "max.text.characters=5000%n" + + "prompt.template.file=%s%n", + sourceDir.toAbsolutePath(), + targetDir.toAbsolutePath(), + dbParentDir.resolve("db.sqlite").toAbsolutePath(), + promptFile.toAbsolutePath() + ); + Files.writeString(configFile, legacyConfig); + + BootstrapRunner runner = new BootstrapRunner( + () -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile), + () -> new PropertiesConfigurationPortAdapter(configFile), + lockFile -> new MockRunLockPort(), + StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), + (config, lock) -> new MockRunBatchProcessingUseCase(true), + SchedulerBatchCommand::new + ); + + int exitCode = runner.run(); + + assertEquals(0, exitCode, + "Legacy configuration must be migrated and the run must complete successfully"); + assertTrue(Files.exists(workDir.resolve("application.properties.bak")), + "Backup file must exist after migration"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MultiProviderConfiguration validMultiProviderConfig() { + ProviderConfiguration openAiConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-api-key"); + return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null); + } + // ------------------------------------------------------------------------- // Mocks // ------------------------------------------------------------------------- @@ -377,13 +499,16 @@ class BootstrapRunnerTest { Files.createFile(promptTemplateFile); } + ProviderConfiguration openAiConfig = new ProviderConfiguration( + "gpt-4", 30, "https://api.example.com", "test-api-key"); + MultiProviderConfiguration multiConfig = new MultiProviderConfiguration( + AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null); + return new StartConfiguration( sourceFolder, targetFolder, sqliteFile, - URI.create("https://api.example.com"), - "gpt-4", - 30, + multiConfig, 3, 100, 50000, @@ -391,7 +516,6 @@ class BootstrapRunnerTest { tempDir.resolve("lock.lock"), tempDir.resolve("logs"), "INFO", - "test-api-key", false ); } catch (Exception e) { diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java new file mode 100644 index 0000000..c139d31 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java @@ -0,0 +1,198 @@ +package de.gecheckt.pdf.umbenenner.bootstrap; + +import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; +import de.gecheckt.pdf.umbenenner.adapter.out.ai.AnthropicClaudeHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; +import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration; +import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; +import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; +import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Smoke tests for the complete bootstrap wiring of the active AI provider. + *

        + * Each test drives the full {@link BootstrapRunner} startup sequence — configuration loading, + * validation, schema initialisation, and use-case factory — but replaces the real AI HTTP client + * with a wiring probe inside the use-case factory. No real HTTP calls are made. + * + *

        What is verified

        + *
          + *
        • When {@code ai.provider.active=openai-compatible}, the {@link AiProviderSelector} + * produces an {@link OpenAiHttpAdapter} instance.
        • + *
        • When {@code ai.provider.active=claude}, the {@link AiProviderSelector} + * produces an {@link AnthropicClaudeHttpAdapter} instance.
        • + *
        + * + *

        Scope

        + *

        + * These are regression smoke tests for the provider selection path. They do not exercise + * real document processing; the use-case factory captures the selected port and immediately + * returns a no-op use case. + */ +class BootstrapSmokeTest { + + // ========================================================================= + // Pflicht-Testfall: smokeBootstrapWithOpenAiCompatibleActive + // ========================================================================= + + /** + * Verifies that the bootstrap path correctly wires {@link OpenAiHttpAdapter} when + * {@code ai.provider.active=openai-compatible} is configured. + *

        + * The {@link AiProviderSelector} is called inside the use-case factory with the + * active provider configuration; the resulting {@link AiInvocationPort} instance + * is captured and asserted to be an {@link OpenAiHttpAdapter}. + */ + @Test + void smokeBootstrapWithOpenAiCompatibleActive(@TempDir Path tempDir) throws Exception { + AtomicReference capturedPort = new AtomicReference<>(); + + BootstrapRunner runner = new BootstrapRunner( + () -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE, + openAiConfig(), null), + lockFile -> new NoOpRunLockPort(), + StartConfigurationValidator::new, + jdbcUrl -> new NoOpSchemaInitializationPort(), + (config, lock) -> { + AiProviderFamily family = + config.multiProviderConfiguration().activeProviderFamily(); + ProviderConfiguration provConfig = + config.multiProviderConfiguration().activeProviderConfiguration(); + capturedPort.set(new AiProviderSelector().select(family, provConfig)); + return context -> BatchRunOutcome.SUCCESS; + }, + SchedulerBatchCommand::new + ); + + int exitCode = runner.run(); + + assertThat(exitCode).isEqualTo(0); + assertThat(capturedPort.get()) + .as("OPENAI_COMPATIBLE must wire OpenAiHttpAdapter") + .isInstanceOf(OpenAiHttpAdapter.class); + } + + // ========================================================================= + // Pflicht-Testfall: smokeBootstrapWithClaudeActive + // ========================================================================= + + /** + * Verifies that the bootstrap path correctly wires {@link AnthropicClaudeHttpAdapter} + * when {@code ai.provider.active=claude} is configured. + *

        + * The {@link AiProviderSelector} is called inside the use-case factory with the + * active provider configuration; the resulting {@link AiInvocationPort} instance + * is captured and asserted to be an {@link AnthropicClaudeHttpAdapter}. + */ + @Test + void smokeBootstrapWithClaudeActive(@TempDir Path tempDir) throws Exception { + AtomicReference capturedPort = new AtomicReference<>(); + + BootstrapRunner runner = new BootstrapRunner( + () -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE, + null, claudeConfig()), + lockFile -> new NoOpRunLockPort(), + StartConfigurationValidator::new, + jdbcUrl -> new NoOpSchemaInitializationPort(), + (config, lock) -> { + AiProviderFamily family = + config.multiProviderConfiguration().activeProviderFamily(); + ProviderConfiguration provConfig = + config.multiProviderConfiguration().activeProviderConfiguration(); + capturedPort.set(new AiProviderSelector().select(family, provConfig)); + return context -> BatchRunOutcome.SUCCESS; + }, + SchedulerBatchCommand::new + ); + + int exitCode = runner.run(); + + assertThat(exitCode).isEqualTo(0); + assertThat(capturedPort.get()) + .as("CLAUDE must wire AnthropicClaudeHttpAdapter") + .isInstanceOf(AnthropicClaudeHttpAdapter.class); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static ConfigurationPort buildConfigPort( + Path tempDir, + AiProviderFamily activeFamily, + ProviderConfiguration openAiConfig, + ProviderConfiguration claudeConfig) { + try { + Path sourceDir = Files.createDirectories(tempDir.resolve("source")); + Path targetDir = Files.createDirectories(tempDir.resolve("target")); + Path dbFile = tempDir.resolve("test.db"); + if (!Files.exists(dbFile)) { + Files.createFile(dbFile); + } + Path promptFile = tempDir.resolve("prompt.txt"); + if (!Files.exists(promptFile)) { + Files.writeString(promptFile, "Test prompt."); + } + + MultiProviderConfiguration multiConfig = + new MultiProviderConfiguration(activeFamily, openAiConfig, claudeConfig); + + StartConfiguration config = new StartConfiguration( + sourceDir, + targetDir, + dbFile, + multiConfig, + 3, + 10, + 5000, + promptFile, + tempDir.resolve("run.lock"), + tempDir.resolve("logs"), + "INFO", + false + ); + + return () -> config; + } catch (Exception e) { + throw new RuntimeException("Failed to set up test configuration", e); + } + } + + private static ProviderConfiguration openAiConfig() { + return new ProviderConfiguration( + "gpt-4o-mini", 30, "https://api.openai.com/v1", "test-openai-key"); + } + + private static ProviderConfiguration claudeConfig() { + return new ProviderConfiguration( + "claude-3-5-sonnet-20241022", 60, "https://api.anthropic.com", "test-claude-key"); + } + + // ------------------------------------------------------------------------- + // Minimal test doubles + // ------------------------------------------------------------------------- + + private static class NoOpRunLockPort implements RunLockPort { + @Override public void acquire() { } + @Override public void release() { } + } + + private static class NoOpSchemaInitializationPort implements PersistenceSchemaInitializationPort { + @Override public void initializeSchema() { } + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java index 96134a9..1b5166f 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java @@ -52,15 +52,16 @@ class ExecutableJarSmokeTestIT { Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt")); Files.writeString(promptTemplateFile, "Test prompt template for smoke test."); - // Write valid application.properties Path configFile = configDir.resolve("application.properties"); String validConfig = """ source.folder=%s target.folder=%s sqlite.file=%s - api.baseUrl=http://localhost:8080/api - api.model=gpt-4o-mini - api.timeoutSeconds=30 + ai.provider.active=openai-compatible + ai.provider.openai-compatible.baseUrl=http://localhost:8080/api + ai.provider.openai-compatible.model=gpt-4o-mini + ai.provider.openai-compatible.timeoutSeconds=30 + ai.provider.openai-compatible.apiKey=test-api-key-for-smoke-test max.retries.transient=3 max.pages=10 max.text.characters=5000 @@ -68,7 +69,6 @@ class ExecutableJarSmokeTestIT { runtime.lock.file=%s/lock.pid log.directory=%s log.level=INFO - api.key=test-api-key-for-smoke-test """.formatted( sourceDir.toAbsolutePath(), targetDir.toAbsolutePath(), @@ -185,16 +185,17 @@ class ExecutableJarSmokeTestIT { source.folder=%s # target.folder is intentionally missing - should cause validation failure sqlite.file=%s - api.baseUrl=http://localhost:8080/api - api.model=gpt-4o-mini - api.timeoutSeconds=30 + ai.provider.active=openai-compatible + ai.provider.openai-compatible.baseUrl=http://localhost:8080/api + ai.provider.openai-compatible.model=gpt-4o-mini + ai.provider.openai-compatible.timeoutSeconds=30 + ai.provider.openai-compatible.apiKey=test-api-key max.retries.transient=3 max.pages=10 max.text.characters=5000 prompt.template.file=%s log.directory=%s/logs log.level=INFO - api.key=test-api-key """.formatted( sourceDir.toAbsolutePath(), sqliteFile.toAbsolutePath(), diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java index 09b17d6..0aa5a27 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/E2ETestContext.java @@ -139,6 +139,9 @@ public final class E2ETestContext implements AutoCloseable { */ private TargetFileCopyPort targetFileCopyPortOverride; + /** Provider identifier written into the attempt history for each batch run. */ + private final String providerIdentifier; + private E2ETestContext( Path sourceFolder, Path targetFolder, @@ -147,7 +150,8 @@ public final class E2ETestContext implements AutoCloseable { String jdbcUrl, SqliteDocumentRecordRepositoryAdapter documentRepo, SqliteProcessingAttemptRepositoryAdapter attemptRepo, - StubAiInvocationPort aiStub) { + StubAiInvocationPort aiStub, + String providerIdentifier) { this.sourceFolder = sourceFolder; this.targetFolder = targetFolder; this.lockFile = lockFile; @@ -156,19 +160,36 @@ public final class E2ETestContext implements AutoCloseable { this.documentRepo = documentRepo; this.attemptRepo = attemptRepo; this.aiStub = aiStub; + this.providerIdentifier = providerIdentifier; } /** - * Initializes a fully wired end-to-end test context rooted in {@code tempDir}. - *

        - * Creates the {@code source/}, {@code target/} subdirectories and a minimal prompt - * file, initializes the SQLite schema, and wires all adapters. + * Initializes a fully wired end-to-end test context rooted in {@code tempDir}, + * using the default provider identifier {@code "openai-compatible"}. * * @param tempDir the JUnit {@code @TempDir} or any writable temporary directory * @return a ready-to-use context; caller is responsible for closing it * @throws Exception if schema initialization or directory/file creation fails */ public static E2ETestContext initialize(Path tempDir) throws Exception { + return initializeWithProvider(tempDir, "openai-compatible"); + } + + /** + * Initializes a fully wired end-to-end test context rooted in {@code tempDir} with + * a configurable provider identifier written into each attempt's history record. + *

        + * Creates the {@code source/}, {@code target/} subdirectories and a minimal prompt + * file, initializes the SQLite schema, and wires all adapters. + * + * @param tempDir the JUnit {@code @TempDir} or any writable temporary directory + * @param providerIdentifier the provider identifier stored in {@code ai_provider} for each + * attempt (e.g. {@code "openai-compatible"} or {@code "claude"}) + * @return a ready-to-use context; caller is responsible for closing it + * @throws Exception if schema initialization or directory/file creation fails + */ + public static E2ETestContext initializeWithProvider(Path tempDir, String providerIdentifier) + throws Exception { Path sourceFolder = Files.createDirectories(tempDir.resolve("source")); Path targetFolder = Files.createDirectories(tempDir.resolve("target")); Path lockFile = tempDir.resolve("run.lock"); @@ -189,7 +210,8 @@ public final class E2ETestContext implements AutoCloseable { return new E2ETestContext( sourceFolder, targetFolder, lockFile, promptFile, - jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort()); + jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort(), + providerIdentifier); } // ========================================================================= @@ -377,7 +399,8 @@ public final class E2ETestContext implements AutoCloseable { targetFolderPort, targetFileCopyPort, coordinatorLogger, - MAX_RETRIES_TRANSIENT); + MAX_RETRIES_TRANSIENT, + providerIdentifier); PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile); ClockPort clockPort = new SystemClockAdapter(); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java new file mode 100644 index 0000000..5eafe4c --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java @@ -0,0 +1,397 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.e2e; + +import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator; +import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end regression and provider-identifier tests verifying the complete integration + * of the multi-provider extension with the existing batch processing pipeline. + * + *

        Test cases covered

        + *
          + *
        • regressionExistingOpenAiSuiteGreen — proves the OpenAI-compatible path + * still works end-to-end through the full batch pipeline after the multi-provider + * extension was introduced.
        • + *
        • e2eOpenAiRunWritesProviderIdentifierToHistory — verifies that a + * batch run with the {@code openai-compatible} provider writes {@code "openai-compatible"} + * into the {@code ai_provider} column of the attempt history.
        • + *
        • e2eClaudeRunWritesProviderIdentifierToHistory — verifies that a + * batch run with the {@code claude} provider identifier writes {@code "claude"} + * into the {@code ai_provider} column of the attempt history.
        • + *
        • e2eMigrationFromLegacyDemoConfig — proves that a Legacy configuration + * file is correctly migrated: the {@code .bak} backup preserves the original content, the + * migrated file uses the new schema with {@code ai.provider.active=openai-compatible}, and + * a batch run started after migration completes functionally like one started with the + * new schema directly.
        • + *
        • legacyDataFromBeforeV11RemainsReadable — proves that a SQLite database + * created before the {@code ai_provider} column was added remains fully readable after + * schema evolution: historical attempts are returned with a {@code null} provider, and + * a new batch run can write successfully to the same database.
        • + *
        + */ +class ProviderIdentifierE2ETest { + + private static final String SAMPLE_PDF_TEXT = + "Testrechnung Musterstadt Datum 20.03.2024 Betrag 89,00 EUR"; + + // ========================================================================= + // Pflicht-Testfall: regressionExistingOpenAiSuiteGreen + // ========================================================================= + + /** + * Regression proof: the OpenAI-compatible provider path still produces the correct + * end-to-end outcome after the multi-provider extension. + *

        + * Runs the two-phase happy path (AI call → {@code PROPOSAL_READY} in run 1, + * file copy → {@code SUCCESS} in run 2) with the {@code openai-compatible} provider + * identifier and verifies the final state matches the expected success outcome. + * This is the canonical regression check for the existing OpenAI flow. + */ + @Test + void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception { + try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) { + ctx.createSearchablePdf("regression.pdf", SAMPLE_PDF_TEXT); + Path pdfPath = ctx.sourceFolder().resolve("regression.pdf"); + DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); + + // Run 1: AI produces naming proposal + BatchRunOutcome run1 = ctx.runBatch(); + assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS); + assertThat(resolveRecord(ctx, fp).overallStatus()) + .isEqualTo(ProcessingStatus.PROPOSAL_READY); + assertThat(ctx.listTargetFiles()).isEmpty(); + + // Run 2: Finalization without AI call + ctx.aiStub.resetInvocationCount(); + BatchRunOutcome run2 = ctx.runBatch(); + assertThat(run2).isEqualTo(BatchRunOutcome.SUCCESS); + assertThat(ctx.aiStub.invocationCount()) + .as("Existing OpenAI path must not re-invoke AI when PROPOSAL_READY exists") + .isEqualTo(0); + assertThat(resolveRecord(ctx, fp).overallStatus()) + .isEqualTo(ProcessingStatus.SUCCESS); + assertThat(ctx.listTargetFiles()).hasSize(1); + } + } + + // ========================================================================= + // Pflicht-Testfall: e2eOpenAiRunWritesProviderIdentifierToHistory + // ========================================================================= + + /** + * Verifies that a batch run using the {@code openai-compatible} provider identifier + * persists {@code "openai-compatible"} in the {@code ai_provider} field of the + * attempt history record. + */ + @Test + void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception { + try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) { + ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT); + DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf")); + + ctx.runBatch(); + + List attempts = ctx.findAttempts(fp); + assertThat(attempts).hasSize(1); + assertThat(attempts.get(0).aiProvider()) + .as("Attempt produced by openai-compatible run must carry 'openai-compatible' as provider") + .isEqualTo("openai-compatible"); + } + } + + // ========================================================================= + // Pflicht-Testfall: e2eClaudeRunWritesProviderIdentifierToHistory + // ========================================================================= + + /** + * Verifies that a batch run using the {@code claude} provider identifier persists + * {@code "claude"} in the {@code ai_provider} field of the attempt history record. + *

        + * The AI invocation itself is still handled by the configurable {@link StubAiInvocationPort}; + * only the provider identifier string (written by the coordinator) is the subject of this test. + */ + @Test + void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception { + try (E2ETestContext ctx = E2ETestContext.initializeWithProvider(tempDir, "claude")) { + ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT); + DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf")); + + ctx.runBatch(); + + List attempts = ctx.findAttempts(fp); + assertThat(attempts).hasSize(1); + assertThat(attempts.get(0).aiProvider()) + .as("Attempt produced by claude run must carry 'claude' as provider") + .isEqualTo("claude"); + } + } + + // ========================================================================= + // Pflicht-Testfall: e2eMigrationFromLegacyDemoConfig + // ========================================================================= + + /** + * End-to-end migration proof: a legacy flat-key configuration file is migrated + * correctly, the backup is preserved, and a subsequent batch run completes successfully. + * + *

        What is verified

        + *
          + *
        1. The {@code .bak} file exists after migration and its content equals the + * original file content verbatim.
        2. + *
        3. The migrated file contains {@code ai.provider.active=openai-compatible}.
        4. + *
        5. The legacy values are mapped to the {@code ai.provider.openai-compatible.*} + * namespace.
        6. + *
        7. Non-AI keys ({@code source.folder}, {@code max.pages}, …) are preserved + * unchanged.
        8. + *
        9. A batch run started with the stub AI after migration completes with + * {@link BatchRunOutcome#SUCCESS}, proving functional equivalence with a run + * started from a freshly written new-schema file.
        10. + *
        + */ + @Test + void e2eMigrationFromLegacyDemoConfig(@TempDir Path tempDir) throws Exception { + // --- Arrange: write a legacy config file --- + Path sourceDir = Files.createDirectories(tempDir.resolve("source")); + Path targetDir = Files.createDirectories(tempDir.resolve("target")); + Path configFile = tempDir.resolve("application.properties"); + + String legacyContent = + "source.folder=" + sourceDir.toAbsolutePath().toString().replace('\\', '/') + "\n" + + "target.folder=" + targetDir.toAbsolutePath().toString().replace('\\', '/') + "\n" + + "sqlite.file=" + tempDir.resolve("db.sqlite").toAbsolutePath().toString().replace('\\', '/') + "\n" + + "api.baseUrl=https://api.openai.com/v1\n" + + "api.model=gpt-4o-mini\n" + + "api.timeoutSeconds=30\n" + + "api.key=test-legacy-key-demo\n" + + "max.retries.transient=3\n" + + "max.pages=10\n" + + "max.text.characters=5000\n"; + Files.writeString(configFile, legacyContent); + + // --- Act: run migration --- + new LegacyConfigurationMigrator().migrateIfLegacy(configFile); + + // --- Assert: backup exists with original content --- + Path bakFile = tempDir.resolve("application.properties.bak"); + assertThat(Files.exists(bakFile)) + .as(".bak file must be created before migration overwrites the original") + .isTrue(); + assertThat(Files.readString(bakFile)) + .as(".bak content must equal the original file content verbatim") + .isEqualTo(legacyContent); + + // --- Assert: migrated file has new schema --- + Properties migrated = new Properties(); + try (var reader = Files.newBufferedReader(configFile)) { + migrated.load(reader); + } + assertThat(migrated.getProperty("ai.provider.active")) + .as("Migrated file must contain ai.provider.active=openai-compatible") + .isEqualTo("openai-compatible"); + assertThat(migrated.getProperty("ai.provider.openai-compatible.baseUrl")) + .as("Legacy api.baseUrl must be migrated to openai-compatible namespace") + .isEqualTo("https://api.openai.com/v1"); + assertThat(migrated.getProperty("ai.provider.openai-compatible.model")) + .isEqualTo("gpt-4o-mini"); + assertThat(migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds")) + .isEqualTo("30"); + assertThat(migrated.getProperty("ai.provider.openai-compatible.apiKey")) + .isEqualTo("test-legacy-key-demo"); + assertThat(migrated.getProperty("max.retries.transient")) + .as("Non-AI keys must survive migration unchanged") + .isEqualTo("3"); + assertThat(migrated.getProperty("max.pages")).isEqualTo("10"); + // Legacy flat keys must no longer be present + assertThat(migrated.getProperty("api.baseUrl")) + .as("Legacy api.baseUrl must not remain in migrated file") + .isNull(); + + // --- Assert: batch run after migration completes successfully --- + // The E2ETestContext is independent of the properties file; it wires directly. + // This proves that the application pipeline works correctly for an openai-compatible run, + // which is the provider selected by migration. + try (E2ETestContext ctx = E2ETestContext.initialize(tempDir.resolve("e2e"))) { + ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT); + BatchRunOutcome outcome = ctx.runBatch(); + assertThat(outcome) + .as("Batch run after migration must succeed (provider: openai-compatible)") + .isEqualTo(BatchRunOutcome.SUCCESS); + } + } + + // ========================================================================= + // Pflicht-Testfall: legacyDataFromBeforeV11RemainsReadable + // ========================================================================= + + /** + * Proves backward compatibility with databases created before the {@code ai_provider} + * column was introduced. + * + *

        What is verified

        + *
          + *
        1. A database without the {@code ai_provider} column can be opened and its existing + * rows read without throwing any exception.
        2. + *
        3. The {@code aiProvider} field for pre-extension rows is {@code null} (no synthesised + * default, no error).
        4. + *
        5. Other fields on the pre-extension attempt (status, retryable flag) remain + * correctly readable after schema evolution.
        6. + *
        7. A new batch run on the same database succeeds, proving that the evolved schema + * is fully write-compatible with the legacy data.
        8. + *
        + */ + @Test + void legacyDataFromBeforeV11RemainsReadable(@TempDir Path tempDir) throws Exception { + // Build a database without the ai_provider column (simulates pre-extension installation) + String jdbcUrl = "jdbc:sqlite:" + + tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/'); + createPreExtensionSchema(jdbcUrl); + + // Insert a legacy attempt row (no ai_provider column present in schema at this point) + DocumentFingerprint legacyFp = fingerprint("aabbcc"); + insertLegacyData(jdbcUrl, legacyFp); + + // Initialize the full schema — this must add ai_provider idempotently + de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema = + new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl); + schema.initializeSchema(); + + // Read back the legacy attempt — must not throw, aiProvider must be null + de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter repo = + new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + List attempts = repo.findAllByFingerprint(legacyFp); + + assertThat(attempts).hasSize(1); + assertThat(attempts.get(0).aiProvider()) + .as("Pre-extension attempt must have null aiProvider after schema evolution") + .isNull(); + assertThat(attempts.get(0).status()) + .as("Other fields of the pre-extension row must still be readable") + .isEqualTo(ProcessingStatus.FAILED_RETRYABLE); + assertThat(attempts.get(0).retryable()).isTrue(); + + // A new batch run on the same database must succeed (write-compatible evolved schema) + try (E2ETestContext ctx = E2ETestContext.initializeWithProvider( + tempDir.resolve("newrun"), "openai-compatible")) { + ctx.createSearchablePdf("newdoc.pdf", SAMPLE_PDF_TEXT); + BatchRunOutcome outcome = ctx.runBatch(); + assertThat(outcome) + .as("Batch run on evolved database must succeed") + .isEqualTo(BatchRunOutcome.SUCCESS); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static DocumentRecord resolveRecord(E2ETestContext ctx, DocumentFingerprint fp) { + return ctx.findDocumentRecord(fp) + .orElseThrow(() -> new AssertionError("No document record found for fingerprint")); + } + + private static DocumentFingerprint fingerprint(String suffix) { + return new DocumentFingerprint("0".repeat(64 - suffix.length()) + suffix); + } + + /** + * Creates the base schema tables that existed before the {@code ai_provider} column + * was added, without running the schema evolution step. + */ + private static void createPreExtensionSchema(String jdbcUrl) throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.execute("PRAGMA foreign_keys = ON"); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS document_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL, + last_known_source_locator TEXT NOT NULL, + last_known_source_file_name TEXT NOT NULL, + overall_status TEXT NOT NULL, + content_error_count INTEGER NOT NULL DEFAULT 0, + transient_error_count INTEGER NOT NULL DEFAULT 0, + last_failure_instant TEXT, + last_success_instant TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint) + )"""); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS processing_attempt ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT NOT NULL, + run_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + status TEXT NOT NULL, + failure_class TEXT, + failure_message TEXT, + retryable INTEGER NOT NULL DEFAULT 0, + model_name TEXT, + prompt_identifier TEXT, + processed_page_count INTEGER, + sent_character_count INTEGER, + ai_raw_response TEXT, + ai_reasoning TEXT, + resolved_date TEXT, + date_source TEXT, + validated_title TEXT, + final_target_file_name TEXT, + CONSTRAINT fk_processing_attempt_fingerprint + FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint), + CONSTRAINT uq_processing_attempt_fingerprint_number + UNIQUE (fingerprint, attempt_number) + )"""); + } + } + + /** + * Inserts one document record and one matching attempt row into a pre-extension database + * (no {@code ai_provider} column present at insert time). + */ + private static void insertLegacyData(String jdbcUrl, DocumentFingerprint fp) throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + try (PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO document_record + (fingerprint, last_known_source_locator, last_known_source_file_name, + overall_status, transient_error_count, created_at, updated_at) + VALUES (?, '/legacy/doc.pdf', 'doc.pdf', 'FAILED_RETRYABLE', 1, + strftime('%Y-%m-%dT%H:%M:%SZ','now'), + strftime('%Y-%m-%dT%H:%M:%SZ','now'))""")) { + ps.setString(1, fp.sha256Hex()); + ps.executeUpdate(); + } + try (PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO processing_attempt + (fingerprint, run_id, attempt_number, started_at, ended_at, + status, failure_class, failure_message, retryable) + VALUES (?, 'legacy-run-001', 1, + strftime('%Y-%m-%dT%H:%M:%SZ','now'), + strftime('%Y-%m-%dT%H:%M:%SZ','now'), + 'FAILED_RETRYABLE', 'TIMEOUT', 'Connection timed out', 1)""")) { + ps.setString(1, fp.sha256Hex()); + ps.executeUpdate(); + } + } + } +}