1
0

V1.1 Änderungen

This commit is contained in:
2026-04-09 05:42:02 +02:00
parent 39800b6ea8
commit 5099ff4aca
44 changed files with 4912 additions and 957 deletions

View File

@@ -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. # 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. # 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. # Wird automatisch angelegt, wenn er noch nicht existiert.
target.folder=./work/local/target target.folder=./work/local/target
# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie. # SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie.
# Das übergeordnete Verzeichnis muss vorhanden sein. # Das uebergeordnete Verzeichnis muss vorhanden sein.
sqlite.file=./work/local/pdf-umbenenner.db 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. # Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der # Muss eine ganze Zahl >= 1 sein.
# Dokumentstatus auf FAILED_FINAL gesetzt.
max.retries.transient=3 max.retries.transient=3
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als # Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
@@ -42,20 +32,11 @@ max.text.characters=5000
# in der Versuchshistorie. # in der Versuchshistorie.
prompt.template.file=./config/prompts/template.txt 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 # Optionale Parameter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen). # Pfad zur Lock-Datei fuer den Startschutz (verhindert parallele Instanzen).
# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis.
runtime.lock.file=./work/local/pdf-umbenenner.lock runtime.lock.file=./work/local/pdf-umbenenner.lock
# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/. # 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 (DEBUG, INFO, WARN, ERROR). Standard ist INFO.
log.level=INFO log.level=INFO
# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben. # Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben.
# Erlaubte Werte: true oder false. Standard ist false (geschützt). # Erlaubte Werte: true oder false. Standard ist false (geschuetzt).
# Nur für Diagnosezwecke auf true setzen.
log.ai.sensitive=false 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=

View File

@@ -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. # 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 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 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 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 max.retries.transient=1
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf).
max.pages=5 max.pages=5
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
max.text.characters=2000 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 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 # 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 runtime.lock.file=./work/test/pdf-umbenenner.lock
# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/.
log.directory=./work/test/logs log.directory=./work/test/logs
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO.
log.level=DEBUG 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 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

View File

@@ -53,26 +53,45 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
- `config/application-local.example.properties` - `config/application-local.example.properties`
- `config/application-test.example.properties` - `config/application-test.example.properties`
### Pflichtparameter ### Pflichtparameter (allgemein)
| Parameter | Beschreibung | | Parameter | Beschreibung |
|------------------------|--------------| |-------------------------|--------------|
| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) | | `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) |
| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) | | `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) |
| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) | | `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) |
| `api.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) | | `ai.provider.active` | Aktiver KI-Provider: `openai-compatible` oder `claude` |
| `api.model` | Modellname (z. B. `gpt-4o-mini`) | | `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
| `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.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (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) | | `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 ### Optionale Parameter
| Parameter | Beschreibung | Standard | | 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 | | `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis |
| `log.directory` | Log-Verzeichnis | `./logs/` | | `log.directory` | Log-Verzeichnis | `./logs/` |
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | | `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
@@ -80,12 +99,52 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
### API-Schlüssel ### 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) | Provider | Umgebungsvariable |
2. Property `api.key` in `config/application.properties` |---|---|
| `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.
--- ---

View File

@@ -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.

View File

@@ -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.
* <p>
* This adapter:
* <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an Anthropic
* Messages API request (POST {@code /v1/messages})</li>
* <li>Configures HTTP connection, timeout, and authentication from the provider
* configuration using the Anthropic-specific authentication scheme
* ({@code x-api-key} header, not {@code Authorization: Bearer})</li>
* <li>Extracts the response text by concatenating all {@code text}-type content
* blocks from the Anthropic response, returning the result as a raw response
* for Application-layer parsing and validation</li>
* <li>Classifies technical failures (HTTP errors, timeouts, missing content blocks,
* unparseable JSON) according to the existing transient error semantics</li>
* </ul>
*
* <h2>Configuration</h2>
* <ul>
* <li>{@code baseUrl} — the HTTP(S) base URL; defaults to {@code https://api.anthropic.com}
* when absent or blank</li>
* <li>{@code model} — the Claude model identifier (e.g., {@code claude-3-5-sonnet-20241022})</li>
* <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token, resolved from environment variable
* {@code ANTHROPIC_API_KEY} or property {@code ai.provider.claude.apiKey};
* environment variable takes precedence (resolved by the configuration layer
* before this adapter is constructed)</li>
* </ul>
*
* <h2>HTTP request structure</h2>
* <p>
* The adapter sends a POST request to {@code {baseUrl}/v1/messages} with:
* <ul>
* <li>Header {@code x-api-key} containing the resolved API key</li>
* <li>Header {@code anthropic-version: 2023-06-01}</li>
* <li>Header {@code content-type: application/json}</li>
* <li>JSON body containing:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code max_tokens} — fixed at 1024; sufficient for the expected JSON response
* without requiring a separate configuration property</li>
* <li>{@code system} — the prompt content (if non-blank); Anthropic uses a
* top-level field instead of a {@code role=system} message</li>
* <li>{@code messages} — an array with exactly one {@code user} message containing
* the document text</li>
* </ul>
* </li>
* </ul>
*
* <h2>Response handling</h2>
* <ul>
* <li><strong>HTTP 200:</strong> All {@code content} blocks with {@code type=="text"}
* are concatenated in order; the result is returned as {@link AiInvocationSuccess}
* with an {@link AiRawResponse} containing the concatenated text. The Application
* layer then parses and validates this text as a NamingProposal JSON object.</li>
* <li><strong>No text blocks in HTTP 200 response:</strong> Classified as a technical
* failure; the Application layer cannot derive a naming proposal without text.</li>
* <li><strong>Unparseable response JSON:</strong> Classified as a technical failure.</li>
* <li><strong>HTTP non-200:</strong> Classified as a technical failure.</li>
* </ul>
*
* <h2>Technical error classification</h2>
* <p>
* All errors are mapped to {@link AiInvocationTechnicalFailure} and follow the existing
* transient error semantics. No new error categories are introduced:
* <ul>
* <li>HTTP 4xx (including 401, 403, 429) and 5xx — technical failure</li>
* <li>Connection timeout, read timeout — {@code TIMEOUT}</li>
* <li>Connection failure — {@code CONNECTION_ERROR}</li>
* <li>DNS failure — {@code DNS_ERROR}</li>
* <li>IO errors — {@code IO_ERROR}</li>
* <li>Interrupted operation — {@code INTERRUPTED}</li>
* <li>JSON not parseable — {@code UNPARSEABLE_JSON}</li>
* <li>No {@code text}-type content block in response — {@code NO_TEXT_CONTENT}</li>
* </ul>
*
* <h2>Non-goals</h2>
* <ul>
* <li>NamingProposal JSON parsing or validation — the Application layer owns this</li>
* <li>Retry logic — this adapter executes a single request only</li>
* <li>Shared implementation with the OpenAI-compatible adapter — no common base class</li>
* </ul>
*/
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.
* <p>
* 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.
* <p>
* 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).
* <p>
* This constructor allows tests to inject a mock or configurable HTTP client
* while keeping configuration validation consistent with the production constructor.
* <p>
* <strong>For testing only:</strong> 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.
* <p>
* 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<String> 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.
* <p>
* Constructs:
* <ul>
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/messages}</li>
* <li>Headers: {@code x-api-key}, {@code anthropic-version: 2023-06-01},
* {@code content-type: application/json}</li>
* <li>Body: JSON with {@code model}, {@code max_tokens}, optional {@code system}
* (prompt content), and {@code messages} with a single user message
* (document text)</li>
* <li>Timeout: configured timeout from provider configuration</li>
* </ul>
*
* @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.
* <p>
* 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.
* <p>
* The body contains:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code max_tokens} — fixed value sufficient for the expected response</li>
* <li>{@code system} — the prompt content as a top-level field (only when non-blank;
* Anthropic does not accept {@code role=system} inside the {@code messages} array)</li>
* <li>{@code messages} — an array with exactly one user message containing the
* document text</li>
* </ul>
* <p>
* <strong>Package-private for testing:</strong> 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.
* <p>
* 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.
* <p>
* <strong>For testing only:</strong> 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<String> executeRequest(HttpRequest httpRequest)
throws java.io.IOException, InterruptedException {
return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
}
}

View File

@@ -11,7 +11,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.json.JSONObject; 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.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult; 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.AiInvocationSuccess;
@@ -26,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* <ul> * <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat * <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
* Completions API request</li> * Completions API request</li>
* <li>Configures HTTP connection, timeout, and authentication from the startup configuration</li> * <li>Configures HTTP connection, timeout, and authentication from the provider configuration</li>
* <li>Executes the HTTP request against the configured AI endpoint</li> * <li>Executes the HTTP request against the configured AI endpoint</li>
* <li>Distinguishes between successful HTTP responses (200) and technical failures * <li>Distinguishes between successful HTTP responses (200) and technical failures
* (timeout, unreachable, connection error, etc.)</li> * (timeout, unreachable, connection error, etc.)</li>
@@ -36,16 +36,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* <p> * <p>
* <strong>Configuration:</strong> * <strong>Configuration:</strong>
* <ul> * <ul>
* <li>{@code apiBaseUrl} — the HTTP(S) base URL of the AI service endpoint</li> * <li>{@code baseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
* <li>{@code apiModel} — the model identifier requested from the AI service</li> * <li>{@code model} — the model identifier requested from the AI service</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li> * <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token (already resolved from environment * <li>{@code apiKey} — the authentication token (resolved from environment variable
* variable {@code PDF_UMBENENNER_API_KEY} or property {@code api.key}, * {@code OPENAI_COMPATIBLE_API_KEY} or property {@code ai.provider.openai-compatible.apiKey},
* environment variable takes precedence)</li> * environment variable takes precedence)</li>
* </ul> * </ul>
* <p> * <p>
* <strong>HTTP request structure:</strong> * <strong>HTTP request structure:</strong>
* The adapter sends a POST request to the endpoint {@code {apiBaseUrl}/v1/chat/completions} * The adapter sends a POST request to the endpoint {@code {baseUrl}/v1/chat/completions}
* with: * with:
* <ul> * <ul>
* <li>Authorization header containing the API key</li> * <li>Authorization header containing the API key</li>
@@ -106,19 +106,18 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
private volatile String lastBuiltJsonBody; private volatile String lastBuiltJsonBody;
/** /**
* Creates an adapter with configuration from startup configuration. * Creates an adapter from the OpenAI-compatible provider configuration.
* <p> * <p>
* The adapter initializes an HTTP client with the configured timeout and creates * The adapter initializes an HTTP client with the configured timeout and parses
* the endpoint URL from the base URL. Configuration values are validated for * the endpoint URI from the configured base URL string.
* null/empty during initialization.
* *
* @param config the startup configuration containing API settings; must not be null * @param config the provider configuration for the OpenAI-compatible family; must not be null
* @throws NullPointerException if config is null * @throws NullPointerException if config is null
* @throws IllegalArgumentException if API base URL or model is missing/empty * @throws IllegalArgumentException if the base URL or model is missing/blank
*/ */
public OpenAiHttpAdapter(StartConfiguration config) { public OpenAiHttpAdapter(ProviderConfiguration config) {
this(config, HttpClient.newBuilder() this(config, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds())) .connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
.build()); .build());
} }
@@ -130,25 +129,25 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
* <p> * <p>
* <strong>For testing only:</strong> This is package-private to remain internal to the adapter. * <strong>For testing only:</strong> This is package-private to remain internal to the adapter.
* *
* @param config the startup configuration containing API settings; must not be null * @param config the provider configuration; must not be null
* @param httpClient the HTTP client to use; must not be null * @param httpClient the HTTP client to use; must not be null
* @throws NullPointerException if config or httpClient is null * @throws NullPointerException if config or httpClient is null
* @throws IllegalArgumentException if API base URL or model is missing/empty * @throws IllegalArgumentException if the base URL or model is missing/blank
*/ */
OpenAiHttpAdapter(StartConfiguration config, HttpClient httpClient) { OpenAiHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
Objects.requireNonNull(config, "config must not be null"); Objects.requireNonNull(config, "config must not be null");
Objects.requireNonNull(httpClient, "httpClient must not be null"); Objects.requireNonNull(httpClient, "httpClient must not be null");
if (config.apiBaseUrl() == null) { if (config.baseUrl() == null || config.baseUrl().isBlank()) {
throw new IllegalArgumentException("API base URL must not be null"); throw new IllegalArgumentException("API base URL must not be null");
} }
if (config.apiModel() == null || config.apiModel().isBlank()) { if (config.model() == null || config.model().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty"); throw new IllegalArgumentException("API model must not be null or empty");
} }
this.apiBaseUrl = config.apiBaseUrl(); this.apiBaseUrl = URI.create(config.baseUrl());
this.apiModel = config.apiModel(); this.apiModel = config.model();
this.apiKey = config.apiKey() != null ? config.apiKey() : ""; this.apiKey = config.apiKey() != null ? config.apiKey() : "";
this.apiTimeoutSeconds = config.apiTimeoutSeconds(); this.apiTimeoutSeconds = config.timeoutSeconds();
this.httpClient = httpClient; this.httpClient = httpClient;
LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s", LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
@@ -229,7 +228,7 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li> * <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li>
* <li>Headers: Authorization with Bearer token, Content-Type application/json</li> * <li>Headers: Authorization with Bearer token, Content-Type application/json</li>
* <li>Body: JSON with model, messages (system = prompt, user = document text)</li> * <li>Body: JSON with model, messages (system = prompt, user = document text)</li>
* <li>Timeout: configured timeout from startup configuration</li> * <li>Timeout: configured timeout from provider configuration</li>
* </ul> * </ul>
* *
* @param request the request representation with prompt and document text * @param request the request representation with prompt and document text

View File

@@ -10,6 +10,7 @@ import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Validates {@link StartConfiguration} before processing can begin. * Validates {@link StartConfiguration} before processing can begin.
* <p> * <p>
@@ -156,13 +157,13 @@ public class StartConfigurationValidator {
validateSourceFolder(config.sourceFolder(), errors); validateSourceFolder(config.sourceFolder(), errors);
validateTargetFolder(config.targetFolder(), errors); validateTargetFolder(config.targetFolder(), errors);
validateSqliteFile(config.sqliteFile(), errors); validateSqliteFile(config.sqliteFile(), errors);
validateApiBaseUrl(config.apiBaseUrl(), errors);
validateApiModel(config.apiModel(), errors);
validatePromptTemplateFile(config.promptTemplateFile(), errors); validatePromptTemplateFile(config.promptTemplateFile(), errors);
if (config.multiProviderConfiguration() == null) {
errors.add("- ai provider configuration: must not be null");
}
} }
private void validateNumericConstraints(StartConfiguration config, List<String> errors) { private void validateNumericConstraints(StartConfiguration config, List<String> errors) {
validateApiTimeoutSeconds(config.apiTimeoutSeconds(), errors);
validateMaxRetriesTransient(config.maxRetriesTransient(), errors); validateMaxRetriesTransient(config.maxRetriesTransient(), errors);
validateMaxPages(config.maxPages(), errors); validateMaxPages(config.maxPages(), errors);
validateMaxTextCharacters(config.maxTextCharacters(), errors); validateMaxTextCharacters(config.maxTextCharacters(), errors);
@@ -199,33 +200,6 @@ public class StartConfigurationValidator {
validateRequiredFileParentDirectory(sqliteFile, "sqlite.file", errors); validateRequiredFileParentDirectory(sqliteFile, "sqlite.file", errors);
} }
private void validateApiBaseUrl(java.net.URI apiBaseUrl, List<String> errors) {
if (apiBaseUrl == null) {
errors.add("- api.baseUrl: must not be null");
return;
}
if (!apiBaseUrl.isAbsolute()) {
errors.add("- api.baseUrl: must be an absolute URI: " + apiBaseUrl);
return;
}
String scheme = apiBaseUrl.getScheme();
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
errors.add("- api.baseUrl: scheme must be http or https, got: " + scheme);
}
}
private void validateApiModel(String apiModel, List<String> errors) {
if (apiModel == null || apiModel.isBlank()) {
errors.add("- api.model: must not be null or blank");
}
}
private void validateApiTimeoutSeconds(int apiTimeoutSeconds, List<String> errors) {
if (apiTimeoutSeconds <= 0) {
errors.add("- api.timeoutSeconds: must be > 0, got: " + apiTimeoutSeconds);
}
}
private void validateMaxRetriesTransient(int maxRetriesTransient, List<String> errors) { private void validateMaxRetriesTransient(int maxRetriesTransient, List<String> errors) {
if (maxRetriesTransient < 1) { if (maxRetriesTransient < 1) {
errors.add("- max.retries.transient: must be >= 1, got: " + maxRetriesTransient); errors.add("- max.retries.transient: must be >= 1, got: " + maxRetriesTransient);

View File

@@ -0,0 +1,306 @@
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.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
/**
* Detects and migrates a legacy flat-key configuration file to the multi-provider schema.
*
* <h2>Legacy form</h2>
* A configuration file is considered legacy if it contains at least one of the flat property
* keys ({@code api.baseUrl}, {@code api.model}, {@code api.timeoutSeconds}, {@code api.key})
* and does <em>not</em> already contain {@code ai.provider.active}.
*
* <h2>Migration procedure</h2>
* <ol>
* <li>Detect legacy form; if absent, return immediately without any I/O side effect.</li>
* <li>Create a {@code .bak} backup of the original file before any changes. If a {@code .bak}
* file already exists, a numbered suffix is appended ({@code .bak.1}, {@code .bak.2}, …).
* Existing backups are never overwritten.</li>
* <li>Rewrite the file:
* <ul>
* <li>{@code api.baseUrl} → {@code ai.provider.openai-compatible.baseUrl}</li>
* <li>{@code api.model} → {@code ai.provider.openai-compatible.model}</li>
* <li>{@code api.timeoutSeconds} → {@code ai.provider.openai-compatible.timeoutSeconds}</li>
* <li>{@code api.key} → {@code ai.provider.openai-compatible.apiKey}</li>
* <li>{@code ai.provider.active=openai-compatible} is appended.</li>
* <li>A commented placeholder section for the Claude provider is appended.</li>
* <li>All other keys are carried over unchanged in stable order.</li>
* </ul>
* </li>
* <li>Write the migrated content via a temporary file ({@code <file>.tmp}) followed by an
* atomic move/rename. The original file is never partially overwritten.</li>
* <li>Reload the migrated file and validate it with {@link MultiProviderConfigurationParser}
* and {@link MultiProviderConfigurationValidator}. If validation fails, a
* {@link ConfigurationLoadingException} is thrown; the {@code .bak} is preserved.</li>
* </ol>
*/
public class LegacyConfigurationMigrator {
private static final Logger LOG = LogManager.getLogger(LegacyConfigurationMigrator.class);
/** Legacy flat key for base URL, replaced during migration. */
static final String LEGACY_BASE_URL = "api.baseUrl";
/** Legacy flat key for model name, replaced during migration. */
static final String LEGACY_MODEL = "api.model";
/** Legacy flat key for timeout, replaced during migration. */
static final String LEGACY_TIMEOUT = "api.timeoutSeconds";
/** Legacy flat key for API key, replaced during migration. */
static final String LEGACY_API_KEY = "api.key";
private static final String[][] LEGACY_KEY_MAPPINGS = {
{LEGACY_BASE_URL, "ai.provider.openai-compatible.baseUrl"},
{LEGACY_MODEL, "ai.provider.openai-compatible.model"},
{LEGACY_TIMEOUT, "ai.provider.openai-compatible.timeoutSeconds"},
{LEGACY_API_KEY, "ai.provider.openai-compatible.apiKey"},
};
private final MultiProviderConfigurationParser parser;
private final MultiProviderConfigurationValidator validator;
/**
* Creates a migrator backed by default parser and validator instances.
*/
public LegacyConfigurationMigrator() {
this(new MultiProviderConfigurationParser(), new MultiProviderConfigurationValidator());
}
/**
* Creates a migrator with injected parser and validator.
* <p>
* Intended for testing, where a controlled (e.g. always-failing) validator can be supplied
* to verify that the {@code .bak} backup is preserved when post-migration validation fails.
*
* @param parser parser used to re-read the migrated file; must not be {@code null}
* @param validator validator used to verify the migrated file; must not be {@code null}
*/
public LegacyConfigurationMigrator(MultiProviderConfigurationParser parser,
MultiProviderConfigurationValidator validator) {
this.parser = parser;
this.validator = validator;
}
/**
* Migrates the configuration file at {@code configFilePath} if it is in legacy form.
* <p>
* If the file does not contain legacy flat keys or already contains
* {@code ai.provider.active}, this method returns immediately without any I/O side effect.
*
* @param configFilePath path to the configuration file; must exist and be readable
* @throws ConfigurationLoadingException if the file cannot be read, the backup cannot be
* created, the migrated file cannot be written, or post-migration validation fails
*/
public void migrateIfLegacy(Path configFilePath) {
String originalContent = readFile(configFilePath);
Properties props = parsePropertiesFromContent(originalContent);
if (!isLegacyForm(props)) {
return;
}
LOG.info("Legacy configuration format detected. Migrating: {}", configFilePath);
createBakBackup(configFilePath, originalContent);
String migratedContent = generateMigratedContent(originalContent);
writeAtomically(configFilePath, migratedContent);
LOG.info("Configuration file migrated to multi-provider schema: {}", configFilePath);
validateMigratedFile(configFilePath);
}
/**
* Returns {@code true} if the given properties are in legacy form.
* <p>
* A properties set is considered legacy when it contains at least one of the four
* flat legacy keys and does not already contain {@code ai.provider.active}.
*
* @param props the parsed properties to inspect; must not be {@code null}
* @return {@code true} if migration is required, {@code false} otherwise
*/
boolean isLegacyForm(Properties props) {
boolean hasLegacyKey = props.containsKey(LEGACY_BASE_URL)
|| props.containsKey(LEGACY_MODEL)
|| props.containsKey(LEGACY_TIMEOUT)
|| props.containsKey(LEGACY_API_KEY);
boolean hasNewKey = props.containsKey(MultiProviderConfigurationParser.PROP_ACTIVE_PROVIDER);
return hasLegacyKey && !hasNewKey;
}
/**
* Creates a backup of the original file before overwriting it.
* <p>
* If {@code <file>.bak} does not yet exist, it is written directly. Otherwise,
* numbered suffixes ({@code .bak.1}, {@code .bak.2}, …) are tried in ascending order
* until a free slot is found. Existing backups are never overwritten.
*/
private void createBakBackup(Path configFilePath, String content) {
Path bakPath = configFilePath.resolveSibling(configFilePath.getFileName() + ".bak");
if (!Files.exists(bakPath)) {
writeFile(bakPath, content);
LOG.info("Backup created: {}", bakPath);
return;
}
for (int i = 1; ; i++) {
Path numbered = configFilePath.resolveSibling(configFilePath.getFileName() + ".bak." + i);
if (!Files.exists(numbered)) {
writeFile(numbered, content);
LOG.info("Backup created: {}", numbered);
return;
}
}
}
/**
* Produces the migrated file content from the given original content string.
* <p>
* Each line is inspected: lines that define a legacy key are rewritten with the
* corresponding new namespaced key; all other lines (comments, blank lines, other keys)
* pass through unchanged. After all original lines, a {@code ai.provider.active} entry
* and a commented Claude-provider placeholder block are appended.
*
* @param originalContent the raw original file content; must not be {@code null}
* @return the migrated content ready to be written to disk
*/
String generateMigratedContent(String originalContent) {
String[] lines = originalContent.split("\\r?\\n", -1);
StringBuilder sb = new StringBuilder();
for (String line : lines) {
sb.append(transformLine(line)).append("\n");
}
sb.append("\n");
sb.append("# Aktiver KI-Provider: openai-compatible oder claude\n");
sb.append("ai.provider.active=openai-compatible\n");
sb.append("\n");
sb.append("# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude)\n");
sb.append("# ai.provider.claude.model=\n");
sb.append("# ai.provider.claude.timeoutSeconds=\n");
sb.append("# ai.provider.claude.apiKey=\n");
return sb.toString();
}
/**
* Transforms a single properties-file line, replacing a legacy key with its new equivalent.
* <p>
* Comment lines, blank lines, and lines defining keys other than the four legacy keys
* are returned unchanged.
*/
private String transformLine(String line) {
for (String[] mapping : LEGACY_KEY_MAPPINGS) {
String legacyKey = mapping[0];
String newKey = mapping[1];
if (lineDefinesKey(line, legacyKey)) {
int keyStart = line.indexOf(legacyKey);
return line.substring(0, keyStart) + newKey + line.substring(keyStart + legacyKey.length());
}
}
return line;
}
/**
* Returns {@code true} when {@code line} defines the given {@code key}.
* <p>
* A line defines a key if — after stripping any leading whitespace — it starts with
* the exact key string followed by {@code =}, {@code :}, whitespace, or end-of-string.
* Comment-introducing characters ({@code #} or {@code !}) cause an immediate {@code false}.
*/
private boolean lineDefinesKey(String line, String key) {
String trimmed = line.stripLeading();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) {
return false;
}
if (!trimmed.startsWith(key)) {
return false;
}
if (trimmed.length() == key.length()) {
return true;
}
char next = trimmed.charAt(key.length());
return next == '=' || next == ':' || Character.isWhitespace(next);
}
/**
* Writes {@code content} to {@code target} via a temporary file and an atomic rename.
* <p>
* The temporary file is created as {@code <target>.tmp} in the same directory.
* After the content is fully written, the temporary file is moved to {@code target},
* replacing it. The original file is therefore never partially overwritten.
*/
private void writeAtomically(Path target, String content) {
Path tmpPath = target.resolveSibling(target.getFileName() + ".tmp");
try {
Files.writeString(tmpPath, content, StandardCharsets.UTF_8);
Files.move(tmpPath, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new ConfigurationLoadingException(
"Failed to write migrated configuration to " + target, e);
}
}
/**
* Re-reads the migrated file and validates it using the injected parser and validator.
* <p>
* A parse or validation failure is treated as a hard startup error. The {@code .bak} backup
* created before migration is preserved in this case.
*/
private void validateMigratedFile(Path configFilePath) {
String content = readFile(configFilePath);
Properties props = parsePropertiesFromContent(content);
MultiProviderConfiguration config;
try {
config = parser.parse(props);
} catch (ConfigurationLoadingException e) {
throw new ConfigurationLoadingException(
"Migrated configuration failed to parse: " + e.getMessage(), e);
}
try {
validator.validate(config);
} catch (InvalidStartConfigurationException e) {
throw new ConfigurationLoadingException(
"Migrated configuration failed validation (backup preserved): " + e.getMessage(), e);
}
}
private String readFile(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to read file: " + path, e);
}
}
private void writeFile(Path path, String content) {
try {
Files.writeString(path, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to write file: " + path, e);
}
}
private Properties parsePropertiesFromContent(String content) {
Properties props = new Properties();
try {
props.load(new StringReader(content));
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to parse properties content", e);
}
return props;
}
}

View File

@@ -0,0 +1,203 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
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 java.util.Properties;
import java.util.function.Function;
/**
* Parses the multi-provider configuration schema from a {@link Properties} object.
* <p>
* Recognises the following property keys:
* <pre>
* ai.provider.active required; must be "openai-compatible" or "claude"
* ai.provider.openai-compatible.baseUrl required for active OpenAI-compatible provider
* ai.provider.openai-compatible.model required for active OpenAI-compatible provider
* ai.provider.openai-compatible.timeoutSeconds
* ai.provider.openai-compatible.apiKey
* ai.provider.claude.baseUrl optional; defaults to https://api.anthropic.com
* ai.provider.claude.model required for active Claude provider
* ai.provider.claude.timeoutSeconds
* ai.provider.claude.apiKey
* </pre>
*
* <h2>Environment-variable precedence for API keys</h2>
* <ul>
* <li>{@code OPENAI_COMPATIBLE_API_KEY} overrides {@code ai.provider.openai-compatible.apiKey}</li>
* <li>{@code ANTHROPIC_API_KEY} overrides {@code ai.provider.claude.apiKey}</li>
* </ul>
* Each environment variable is applied only to its own provider family; the variables
* of different families are never mixed.
*
* <h2>Error handling</h2>
* <ul>
* <li>If {@code ai.provider.active} is absent or blank, a {@link ConfigurationLoadingException}
* is thrown.</li>
* <li>If {@code ai.provider.active} holds an unrecognised value, a
* {@link ConfigurationLoadingException} is thrown.</li>
* <li>If a {@code timeoutSeconds} property is present but not a valid integer, a
* {@link ConfigurationLoadingException} is thrown.</li>
* <li>Missing optional fields result in {@code null} (String) or {@code 0} (int) stored in
* the returned record; the validator enforces required fields for the active provider.</li>
* </ul>
*
* <p>The returned {@link MultiProviderConfiguration} is not yet validated. Use
* {@link MultiProviderConfigurationValidator} after parsing.
*/
public class MultiProviderConfigurationParser {
/** Property key selecting the active provider family. */
static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
static final String PROP_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
static final String PROP_OPENAI_MODEL = "ai.provider.openai-compatible.model";
static final String PROP_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
static final String PROP_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
static final String PROP_CLAUDE_MODEL = "ai.provider.claude.model";
static final String PROP_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
static final String PROP_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
/** Environment variable for the OpenAI-compatible provider API key. */
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
/** Environment variable for the Anthropic Claude provider API key. */
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
/** Default base URL for the Anthropic Claude provider when not explicitly configured. */
static final String CLAUDE_DEFAULT_BASE_URL = "https://api.anthropic.com";
private final Function<String, String> environmentLookup;
/**
* Creates a parser that uses the real system environment for API key resolution.
*/
public MultiProviderConfigurationParser() {
this(System::getenv);
}
/**
* Creates a parser with a custom environment lookup function.
* <p>
* This constructor is intended for testing to allow deterministic control over
* environment variable values without modifying the real process environment.
*
* @param environmentLookup a function that maps environment variable names to their values;
* must not be {@code null}
*/
public MultiProviderConfigurationParser(Function<String, String> environmentLookup) {
this.environmentLookup = environmentLookup;
}
/**
* Parses the multi-provider configuration from the given properties.
* <p>
* The Claude default base URL ({@code https://api.anthropic.com}) is applied when
* {@code ai.provider.claude.baseUrl} is absent. API keys are resolved with environment
* variable precedence. The resulting configuration is not yet validated; call
* {@link MultiProviderConfigurationValidator#validate(MultiProviderConfiguration)} afterward.
*
* @param props the properties to parse; must not be {@code null}
* @return the parsed (but not yet validated) multi-provider configuration
* @throws ConfigurationLoadingException if {@code ai.provider.active} is absent, blank,
* or holds an unrecognised value, or if any present timeout property is not a
* valid integer
*/
public MultiProviderConfiguration parse(Properties props) {
AiProviderFamily activeFamily = parseActiveProvider(props);
ProviderConfiguration openAiConfig = parseOpenAiCompatibleConfig(props);
ProviderConfiguration claudeConfig = parseClaudeConfig(props);
return new MultiProviderConfiguration(activeFamily, openAiConfig, claudeConfig);
}
private AiProviderFamily parseActiveProvider(Properties props) {
String raw = props.getProperty(PROP_ACTIVE_PROVIDER);
if (raw == null || raw.isBlank()) {
throw new ConfigurationLoadingException(
"Required property missing or blank: " + PROP_ACTIVE_PROVIDER
+ ". Valid values: openai-compatible, claude");
}
String trimmed = raw.trim();
return AiProviderFamily.fromIdentifier(trimmed).orElseThrow(() ->
new ConfigurationLoadingException(
"Unknown provider identifier for " + PROP_ACTIVE_PROVIDER + ": '" + trimmed
+ "'. Valid values: openai-compatible, claude"));
}
private ProviderConfiguration parseOpenAiCompatibleConfig(Properties props) {
String model = getOptionalString(props, PROP_OPENAI_MODEL);
int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL);
String apiKey = resolveApiKey(props, PROP_OPENAI_API_KEY, ENV_OPENAI_API_KEY);
return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
}
private ProviderConfiguration parseClaudeConfig(Properties props) {
String model = getOptionalString(props, PROP_CLAUDE_MODEL);
int timeout = parseTimeoutSeconds(props, PROP_CLAUDE_TIMEOUT);
String baseUrl = getStringOrDefault(props, PROP_CLAUDE_BASE_URL, CLAUDE_DEFAULT_BASE_URL);
String apiKey = resolveApiKey(props, PROP_CLAUDE_API_KEY, ENV_CLAUDE_API_KEY);
return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
}
/**
* Returns the trimmed property value, or {@code null} if absent or blank.
*/
private String getOptionalString(Properties props, String key) {
String value = props.getProperty(key);
return (value == null || value.isBlank()) ? null : value.trim();
}
/**
* Returns the trimmed property value, or the {@code defaultValue} if absent or blank.
*/
private String getStringOrDefault(Properties props, String key, String defaultValue) {
String value = props.getProperty(key);
return (value == null || value.isBlank()) ? defaultValue : value.trim();
}
/**
* Parses a timeout property as a positive integer.
* <p>
* Returns {@code 0} when the property is absent or blank (indicating "not configured").
* Throws {@link ConfigurationLoadingException} when the property is present but not
* parseable as an integer.
*/
private int parseTimeoutSeconds(Properties props, String key) {
String value = props.getProperty(key);
if (value == null || value.isBlank()) {
return 0;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
throw new ConfigurationLoadingException(
"Invalid integer value for property " + key + ": '" + value.trim() + "'", e);
}
}
/**
* Resolves the effective API key for a provider family.
* <p>
* The environment variable value takes precedence over the properties value.
* If the environment variable is absent or blank, the properties value is used.
* If both are absent or blank, an empty string is returned (the validator will
* reject this for the active provider).
*
* @param props the configuration properties
* @param propertyKey the property key for the API key of this provider family
* @param envVarName the environment variable name for this provider family
* @return the resolved API key; never {@code null}, but may be blank
*/
private String resolveApiKey(Properties props, String propertyKey, String envVarName) {
String envValue = environmentLookup.apply(envVarName);
if (envValue != null && !envValue.isBlank()) {
return envValue.trim();
}
String propsValue = props.getProperty(propertyKey);
return (propsValue != null) ? propsValue.trim() : "";
}
}

View File

@@ -0,0 +1,106 @@
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 de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import java.util.ArrayList;
import java.util.List;
/**
* Validates a {@link MultiProviderConfiguration} before the application run begins.
* <p>
* Enforces all requirements for the active provider:
* <ul>
* <li>{@code ai.provider.active} refers to a recognised provider family.</li>
* <li>{@code model} is non-blank.</li>
* <li>{@code timeoutSeconds} is a positive integer.</li>
* <li>{@code baseUrl} is non-blank (required for the OpenAI-compatible family;
* the Claude family always has a default).</li>
* <li>{@code apiKey} is non-blank after environment-variable precedence has been applied
* by {@link MultiProviderConfigurationParser}.</li>
* </ul>
* Required fields of the <em>inactive</em> provider are intentionally not enforced.
* <p>
* Validation errors are aggregated and reported together in a single
* {@link InvalidStartConfigurationException}.
*/
public class MultiProviderConfigurationValidator {
/**
* Validates the given multi-provider configuration.
* <p>
* Only the active provider's required fields are validated. The inactive provider's
* configuration may be incomplete.
*
* @param config the configuration to validate; must not be {@code null}
* @throws InvalidStartConfigurationException if any validation rule fails, with an aggregated
* message listing all problems found
*/
public void validate(MultiProviderConfiguration config) {
List<String> errors = new ArrayList<>();
validateActiveProvider(config, errors);
if (!errors.isEmpty()) {
throw new InvalidStartConfigurationException(
"Invalid AI provider configuration:\n" + String.join("\n", errors));
}
}
private void validateActiveProvider(MultiProviderConfiguration config, List<String> errors) {
AiProviderFamily activeFamily = config.activeProviderFamily();
if (activeFamily == null) {
// Parser already throws for missing/unknown ai.provider.active,
// but guard defensively in case the record is constructed directly in tests.
errors.add("- ai.provider.active: must be set to a supported provider "
+ "(openai-compatible, claude)");
return;
}
ProviderConfiguration providerConfig = config.activeProviderConfiguration();
String providerLabel = "ai.provider." + activeFamily.getIdentifier();
validateModel(providerConfig, providerLabel, errors);
validateTimeoutSeconds(providerConfig, providerLabel, errors);
validateBaseUrl(activeFamily, providerConfig, providerLabel, errors);
validateApiKey(providerConfig, providerLabel, errors);
}
private void validateModel(ProviderConfiguration config, String providerLabel, List<String> errors) {
if (config.model() == null || config.model().isBlank()) {
errors.add("- " + providerLabel + ".model: must not be blank");
}
}
private void validateTimeoutSeconds(ProviderConfiguration config, String providerLabel,
List<String> errors) {
if (config.timeoutSeconds() <= 0) {
errors.add("- " + providerLabel + ".timeoutSeconds: must be a positive integer, got: "
+ config.timeoutSeconds());
}
}
/**
* Validates base URL presence.
* <p>
* The OpenAI-compatible family requires an explicit base URL.
* The Claude family always has a default ({@code https://api.anthropic.com}) applied by the
* parser, so this check is a safety net rather than a primary enforcement mechanism.
*/
private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config,
String providerLabel, List<String> errors) {
if (config.baseUrl() == null || config.baseUrl().isBlank()) {
errors.add("- " + providerLabel + ".baseUrl: must not be blank");
}
}
private void validateApiKey(ProviderConfiguration config, String providerLabel,
List<String> errors) {
if (config.apiKey() == null || config.apiKey().isBlank()) {
errors.add("- " + providerLabel + ".apiKey: must not be blank "
+ "(set via environment variable or properties)");
}
}
}

View File

@@ -2,8 +2,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import java.io.IOException; import java.io.IOException;
import java.io.StringReader; import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -14,22 +12,24 @@ import java.util.function.Function;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
/** /**
* Properties-based implementation of {@link ConfigurationPort}. * Properties-based implementation of {@link ConfigurationPort}.
* <p> * <p>
* Loads configuration from config/application.properties as the primary source. * Loads configuration from {@code config/application.properties} as the primary source.
* For sensitive values, environment variables take precedence: if the environment variable * The multi-provider AI configuration is parsed via {@link MultiProviderConfigurationParser}
* {@code PDF_UMBENENNER_API_KEY} is set, it overrides the {@code api.key} property from the file. * and validated via {@link MultiProviderConfigurationValidator}. Environment variables
* This allows credentials to be managed securely without storing them in the configuration file. * for API keys are resolved by the parser with provider-specific precedence rules:
* {@code OPENAI_COMPATIBLE_API_KEY} for the OpenAI-compatible family and
* {@code ANTHROPIC_API_KEY} for the Anthropic Claude family.
*/ */
public class PropertiesConfigurationPortAdapter implements ConfigurationPort { public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class); private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class);
private static final String DEFAULT_CONFIG_FILE_PATH = "config/application.properties"; private static final String DEFAULT_CONFIG_FILE_PATH = "config/application.properties";
private static final String API_KEY_ENV_VAR = "PDF_UMBENENNER_API_KEY";
private final Function<String, String> environmentLookup; private final Function<String, String> environmentLookup;
private final Path configFilePath; private final Path configFilePath;
@@ -81,8 +81,9 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
@Override @Override
public StartConfiguration loadConfiguration() { public StartConfiguration loadConfiguration() {
Properties props = loadPropertiesFile(); Properties props = loadPropertiesFile();
String apiKey = getApiKey(props); MultiProviderConfiguration multiProviderConfig = parseAndValidateProviders(props);
return buildStartConfiguration(props, apiKey); boolean logAiSensitive = parseAiContentSensitivity(props);
return buildStartConfiguration(props, multiProviderConfig, logAiSensitive);
} }
private Properties loadPropertiesFile() { private Properties loadPropertiesFile() {
@@ -100,22 +101,28 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
return props; return props;
} }
private String escapeBackslashes(String content) { /**
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences. * Parses and validates the multi-provider AI configuration from the given properties.
// This is needed because Windows paths use backslashes (e.g., C:\temp\...) * <p>
// and Java Properties interprets \t as tab, \n as newline, etc. * Uses {@link MultiProviderConfigurationParser} for parsing and
return content.replace("\\", "\\\\"); * {@link MultiProviderConfigurationValidator} for validation. Throws on any
* configuration error before returning.
*/
private MultiProviderConfiguration parseAndValidateProviders(Properties props) {
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(environmentLookup);
MultiProviderConfiguration config = parser.parse(props);
new MultiProviderConfigurationValidator().validate(config);
return config;
} }
private StartConfiguration buildStartConfiguration(Properties props, String apiKey) { private StartConfiguration buildStartConfiguration(Properties props,
boolean logAiSensitive = parseAiContentSensitivity(props); MultiProviderConfiguration multiProviderConfig,
boolean logAiSensitive) {
return new StartConfiguration( return new StartConfiguration(
Paths.get(getRequiredProperty(props, "source.folder")), Paths.get(getRequiredProperty(props, "source.folder")),
Paths.get(getRequiredProperty(props, "target.folder")), Paths.get(getRequiredProperty(props, "target.folder")),
Paths.get(getRequiredProperty(props, "sqlite.file")), Paths.get(getRequiredProperty(props, "sqlite.file")),
parseUri(getRequiredProperty(props, "api.baseUrl")), multiProviderConfig,
getRequiredProperty(props, "api.model"),
parseInt(getRequiredProperty(props, "api.timeoutSeconds")),
parseInt(getRequiredProperty(props, "max.retries.transient")), parseInt(getRequiredProperty(props, "max.retries.transient")),
parseInt(getRequiredProperty(props, "max.pages")), parseInt(getRequiredProperty(props, "max.pages")),
parseInt(getRequiredProperty(props, "max.text.characters")), parseInt(getRequiredProperty(props, "max.text.characters")),
@@ -123,19 +130,15 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
Paths.get(getOptionalProperty(props, "runtime.lock.file", "")), Paths.get(getOptionalProperty(props, "runtime.lock.file", "")),
Paths.get(getOptionalProperty(props, "log.directory", "")), Paths.get(getOptionalProperty(props, "log.directory", "")),
getOptionalProperty(props, "log.level", "INFO"), getOptionalProperty(props, "log.level", "INFO"),
apiKey,
logAiSensitive logAiSensitive
); );
} }
private String getApiKey(Properties props) { private String escapeBackslashes(String content) {
String envApiKey = environmentLookup.apply(API_KEY_ENV_VAR); // Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
if (envApiKey != null && !envApiKey.isBlank()) { // This is needed because Windows paths use backslashes (e.g., C:\temp\...)
LOG.info("Using API key from environment variable {}", API_KEY_ENV_VAR); // and Java Properties interprets \t as tab, \n as newline, etc.
return envApiKey; return content.replace("\\", "\\\\");
}
String propsApiKey = props.getProperty("api.key");
return propsApiKey != null ? propsApiKey : "";
} }
private String getRequiredProperty(Properties props, String key) { private String getRequiredProperty(Properties props, String key) {
@@ -169,14 +172,6 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
} }
} }
private URI parseUri(String value) {
try {
return new URI(value.trim());
} catch (URISyntaxException e) {
throw new ConfigurationLoadingException("Invalid URI value for property: " + value, e);
}
}
/** /**
* Parses the {@code log.ai.sensitive} configuration property with strict validation. * Parses the {@code log.ai.sensitive} configuration property with strict validation.
* <p> * <p>

View File

@@ -31,9 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* including all AI traceability fields added during schema evolution. * including all AI traceability fields added during schema evolution.
* <p> * <p>
* <strong>Schema compatibility:</strong> This adapter writes all columns including * <strong>Schema compatibility:</strong> This adapter writes all columns including
* the AI traceability columns. When reading rows that were written before schema * the AI traceability columns and the provider-identifier column ({@code ai_provider}).
* evolution, those columns contain {@code NULL} and are mapped to {@code null} * When reading rows that were written before schema evolution, those columns contain
* in the Java record. * {@code NULL} and are mapped to {@code null} in the Java record.
* <p> * <p>
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly * <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any * confined to this class. No JDBC types appear in the port interface or in any
@@ -129,6 +129,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
failure_class, failure_class,
failure_message, failure_message,
retryable, retryable,
ai_provider,
model_name, model_name,
prompt_identifier, prompt_identifier,
processed_page_count, processed_page_count,
@@ -139,7 +140,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
date_source, date_source,
validated_title, validated_title,
final_target_file_name final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (Connection connection = getConnection(); try (Connection connection = getConnection();
@@ -157,19 +158,20 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
setNullableString(statement, 7, attempt.failureClass()); setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage()); setNullableString(statement, 8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable()); statement.setBoolean(9, attempt.retryable());
// AI traceability fields // AI provider identifier and AI traceability fields
setNullableString(statement, 10, attempt.modelName()); setNullableString(statement, 10, attempt.aiProvider());
setNullableString(statement, 11, attempt.promptIdentifier()); setNullableString(statement, 11, attempt.modelName());
setNullableInteger(statement, 12, attempt.processedPageCount()); setNullableString(statement, 12, attempt.promptIdentifier());
setNullableInteger(statement, 13, attempt.sentCharacterCount()); setNullableInteger(statement, 13, attempt.processedPageCount());
setNullableString(statement, 14, attempt.aiRawResponse()); setNullableInteger(statement, 14, attempt.sentCharacterCount());
setNullableString(statement, 15, attempt.aiReasoning()); setNullableString(statement, 15, attempt.aiRawResponse());
setNullableString(statement, 16, setNullableString(statement, 16, attempt.aiReasoning());
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
setNullableString(statement, 17, setNullableString(statement, 17,
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
setNullableString(statement, 18,
attempt.dateSource() != null ? attempt.dateSource().name() : null); attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 18, attempt.validatedTitle()); setNullableString(statement, 19, attempt.validatedTitle());
setNullableString(statement, 19, attempt.finalTargetFileName()); setNullableString(statement, 20, attempt.finalTargetFileName());
int rowsAffected = statement.executeUpdate(); int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) { if (rowsAffected != 1) {
@@ -204,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT SELECT
fingerprint, run_id, attempt_number, started_at, ended_at, fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable, status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count, ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title, ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name final_target_file_name
FROM processing_attempt FROM processing_attempt
@@ -255,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT SELECT
fingerprint, run_id, attempt_number, started_at, ended_at, fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable, status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count, ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title, ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name final_target_file_name
FROM processing_attempt FROM processing_attempt
@@ -312,6 +314,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
rs.getString("failure_class"), rs.getString("failure_class"),
rs.getString("failure_message"), rs.getString("failure_message"),
rs.getBoolean("retryable"), rs.getBoolean("retryable"),
rs.getString("ai_provider"),
rs.getString("model_name"), rs.getString("model_name"),
rs.getString("prompt_identifier"), rs.getString("prompt_identifier"),
processedPageCount, processedPageCount,

View File

@@ -41,6 +41,9 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to * <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li> * {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li> * <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
* existing rows receive {@code NULL} as the default, which is the correct value for
* attempts recorded before provider tracking was introduced.</li>
* </ul> * </ul>
* *
* <h2>Legacy-state migration</h2> * <h2>Legacy-state migration</h2>
@@ -150,6 +153,9 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
/** /**
* Columns to add idempotently to {@code processing_attempt}. * Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}. * Each entry is {@code [column_name, column_type]}.
* <p>
* {@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 = { private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"}, {"model_name", "TEXT"},
@@ -162,6 +168,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
{"date_source", "TEXT"}, {"date_source", "TEXT"},
{"validated_title", "TEXT"}, {"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"}, {"final_target_file_name", "TEXT"},
{"ai_provider", "TEXT"},
}; };
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -229,7 +236,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
* <li>Create {@code document_record} table (if not exists).</li> * <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists).</li> * <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create all indexes (if not exist).</li> * <li>Create all indexes (if not exist).</li>
* <li>Add AI-traceability columns to {@code processing_attempt} (idempotent evolution).</li> * <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
* (idempotent evolution).</li>
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li> * <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol> * </ol>
* <p> * <p>

View File

@@ -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.
* <p>
* 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
* <p>
* 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<String> 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<ProcessingAttempt> 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) {}
}
}

View File

@@ -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}.
* <p>
* 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}.
* <p>
* Covered scenarios:
* <ul>
* <li>Correct HTTP request structure (URL, method, headers, body)</li>
* <li>API key resolution (env var vs. properties value)</li>
* <li>Configuration validation for missing API key</li>
* <li>Single and multiple text-block extraction from Anthropic response</li>
* <li>Ignoring non-text content blocks</li>
* <li>Technical failure when no text blocks are present</li>
* <li>HTTP 4xx (401, 429) and 5xx (500) mapped to technical failure</li>
* <li>Timeout mapped to technical failure</li>
* <li>Unparseable JSON response mapped to technical failure</li>
* </ul>
*/
@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<String> 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<HttpRequest> 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<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithEnvKey.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> 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<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithPropertiesKey.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> 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}.
* <p>
* 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> httpResponse = mockHttpResponse(200,
"<html><body>Service unavailable</body></html>");
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<String> 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<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapter.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> 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<String> 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<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithDefault.invoke(createTestRequest("p", "d"));
ArgumentCaptor<HttpRequest> 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<String> mockHttpResponse(int statusCode, String body) {
HttpResponse<String> response = (HttpResponse<String>) 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()
);
}
}

View File

@@ -5,13 +5,11 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import java.net.ConnectException; import java.net.ConnectException;
import java.net.URI;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException; import java.net.http.HttpTimeoutException;
import java.nio.file.Paths;
import java.time.Duration; import java.time.Duration;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -25,11 +23,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; 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.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess; import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure; 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.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
@@ -39,6 +36,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
* <strong>Test strategy:</strong> * <strong>Test strategy:</strong>
* Tests inject a mock {@link HttpClient} via the package-private constructor * Tests inject a mock {@link HttpClient} via the package-private constructor
* to exercise the real HTTP adapter path without requiring network access. * to exercise the real HTTP adapter path without requiring network access.
* Configuration is supplied via {@link ProviderConfiguration}.
* <p> * <p>
* <strong>Coverage goals:</strong> * <strong>Coverage goals:</strong>
* <ul> * <ul>
@@ -56,6 +54,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
* <li>Effective API key is actually used in the Authorization header</li> * <li>Effective API key is actually used in the Authorization header</li>
* <li>Full document text is sent (not truncated)</li> * <li>Full document text is sent (not truncated)</li>
* <li>Null request raises NullPointerException</li> * <li>Null request raises NullPointerException</li>
* <li>Adapter reads all values from ProviderConfiguration (AP-003)</li>
* <li>Behavioral contracts are unchanged after constructor change (AP-003)</li>
* </ul> * </ul>
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -70,28 +70,12 @@ class OpenAiHttpAdapterTest {
@Mock @Mock
private HttpClient httpClient; private HttpClient httpClient;
private StartConfiguration testConfiguration; private ProviderConfiguration testConfiguration;
private OpenAiHttpAdapter adapter; private OpenAiHttpAdapter adapter;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
testConfiguration = new StartConfiguration( testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
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
);
// Use the package-private constructor with injected mock HttpClient // Use the package-private constructor with injected mock HttpClient
adapter = new OpenAiHttpAdapter(testConfiguration, httpClient); adapter = new OpenAiHttpAdapter(testConfiguration, httpClient);
} }
@@ -242,7 +226,6 @@ class OpenAiHttpAdapterTest {
verify(httpClient).send(requestCaptor.capture(), any()); verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue(); HttpRequest capturedRequest = requestCaptor.getValue();
// Verify the timeout was actually configured on the request
assertThat(capturedRequest.timeout()) assertThat(capturedRequest.timeout())
.as("HttpRequest timeout should be present") .as("HttpRequest timeout should be present")
.isPresent() .isPresent()
@@ -437,23 +420,8 @@ class OpenAiHttpAdapterTest {
@Test @Test
@DisplayName("should throw IllegalArgumentException when API base URL is null") @DisplayName("should throw IllegalArgumentException when API base URL is null")
void testNullApiBaseUrlThrowsException() { void testNullApiBaseUrlThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration( ProviderConfiguration invalidConfig = new ProviderConfiguration(
Paths.get("/source"), API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
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
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
@@ -463,23 +431,8 @@ class OpenAiHttpAdapterTest {
@Test @Test
@DisplayName("should throw IllegalArgumentException when API model is null") @DisplayName("should throw IllegalArgumentException when API model is null")
void testNullApiModelThrowsException() { void testNullApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration( ProviderConfiguration invalidConfig = new ProviderConfiguration(
Paths.get("/source"), null, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
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
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
@@ -489,23 +442,8 @@ class OpenAiHttpAdapterTest {
@Test @Test
@DisplayName("should throw IllegalArgumentException when API model is blank") @DisplayName("should throw IllegalArgumentException when API model is blank")
void testBlankApiModelThrowsException() { void testBlankApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration( ProviderConfiguration invalidConfig = new ProviderConfiguration(
Paths.get("/source"), " ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
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
);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient)) assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
@@ -516,25 +454,9 @@ class OpenAiHttpAdapterTest {
@DisplayName("should handle empty API key gracefully") @DisplayName("should handle empty API key gracefully")
void testEmptyApiKeyHandled() throws Exception { void testEmptyApiKeyHandled() throws Exception {
// Arrange // Arrange
StartConfiguration configWithEmptyKey = new StartConfiguration( OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(
Paths.get("/source"), new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""),
Paths.get("/target"), httpClient);
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);
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}"); HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
@@ -548,18 +470,94 @@ class OpenAiHttpAdapterTest {
assertThat(result).isInstanceOf(AiInvocationSuccess.class); 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<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiRequestRepresentation request = createTestRequest("prompt", "document");
nsAdapter.invoke(request);
ArgumentCaptor<HttpRequest> 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<String> 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<String> 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 // Helper methods
/** /**
* Creates a mock HttpResponse with the specified status code and optional body. * Creates a mock HttpResponse with the specified status code and optional body.
* <p>
* 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") @SuppressWarnings("unchecked")
private HttpResponse<String> mockHttpResponse(int statusCode, String body) { private HttpResponse<String> mockHttpResponse(int statusCode, String body) {

View File

@@ -1,10 +1,12 @@
package de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation; 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 de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -23,6 +25,13 @@ class StartConfigurationValidatorTest {
@TempDir @TempDir
Path 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 @Test
void validate_successWithValidConfiguration() throws Exception { void validate_successWithValidConfiguration() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -34,9 +43,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -44,7 +51,6 @@ class StartConfigurationValidatorTest {
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -57,9 +63,7 @@ class StartConfigurationValidatorTest {
null, null,
tempDir.resolve("target"), tempDir.resolve("target"),
tempDir.resolve("db.sqlite"), tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -67,7 +71,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -84,9 +87,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("source"), tempDir.resolve("source"),
null, null,
tempDir.resolve("db.sqlite"), tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -94,7 +95,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -111,9 +111,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("source"), tempDir.resolve("source"),
tempDir.resolve("target"), tempDir.resolve("target"),
null, null,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -121,7 +119,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -133,7 +130,7 @@ class StartConfigurationValidatorTest {
} }
@Test @Test
void validate_failsWhenApiBaseUrlIsNull() throws Exception { void validate_failsWhenMultiProviderConfigurationIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
@@ -144,8 +141,6 @@ class StartConfigurationValidatorTest {
targetFolder, targetFolder,
sqliteFile, sqliteFile,
null, null,
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -153,7 +148,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -161,39 +155,7 @@ class StartConfigurationValidatorTest {
InvalidStartConfigurationException.class, InvalidStartConfigurationException.class,
() -> validator.validate(config) () -> validator.validate(config)
); );
assertTrue(exception.getMessage().contains("api.baseUrl: must not be null")); assertTrue(exception.getMessage().contains("ai provider configuration: 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"));
} }
@Test @Test
@@ -206,9 +168,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -216,7 +176,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -227,38 +186,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("prompt.template.file: must not be null")); 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 @Test
void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception { void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -270,9 +197,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
-1, -1,
100, 100,
50000, 50000,
@@ -280,7 +205,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -302,9 +226,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
0, 0,
100, 100,
50000, 50000,
@@ -312,7 +234,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -335,9 +256,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
0, 0,
50000, 50000,
@@ -345,7 +264,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -367,9 +285,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
-1, -1,
@@ -377,7 +293,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -399,9 +314,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
1, // maxRetriesTransient = 1 is the minimum valid value 1, // maxRetriesTransient = 1 is the minimum valid value
100, 100,
50000, 50000,
@@ -409,7 +322,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -427,9 +339,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
0, // maxTextCharacters = 0 ist ungültig 0, // maxTextCharacters = 0 ist ungültig
@@ -437,7 +347,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -458,9 +367,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("nonexistent"), tempDir.resolve("nonexistent"),
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -468,7 +375,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -490,9 +396,7 @@ class StartConfigurationValidatorTest {
sourceFile, sourceFile,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -500,7 +404,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -513,8 +416,6 @@ class StartConfigurationValidatorTest {
@Test @Test
void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception { 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 sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
@@ -523,9 +424,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
tempDir.resolve("nonexistent-target"), tempDir.resolve("nonexistent-target"),
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -533,7 +432,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -545,7 +443,6 @@ class StartConfigurationValidatorTest {
@Test @Test
void validate_failsWhenTargetFolderCannotBeCreated() { void validate_failsWhenTargetFolderCannotBeCreated() {
// Inject a TargetFolderChecker that simulates a creation failure.
StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator( StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator(
path -> null, // source folder checker always passes path -> null, // source folder checker always passes
path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)" 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("source"),
tempDir.resolve("uncreatable-target"), tempDir.resolve("uncreatable-target"),
tempDir.resolve("db.sqlite"), tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -565,7 +460,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -588,9 +482,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFile, targetFile,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -598,7 +490,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -619,9 +510,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
tempDir.resolve("nonexistent/db.sqlite"), tempDir.resolve("nonexistent/db.sqlite"),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -629,7 +518,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -640,70 +528,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("sqlite.file: parent directory does not exist")); 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 @Test
void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception { void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -714,9 +538,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -724,7 +546,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -746,9 +567,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -756,7 +575,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -777,9 +595,7 @@ class StartConfigurationValidatorTest {
sameFolder, sameFolder,
sameFolder, sameFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -787,7 +603,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -805,8 +620,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
null, null,
null,
0,
-1, -1,
0, 0,
-1, -1,
@@ -814,7 +627,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -826,22 +638,13 @@ class StartConfigurationValidatorTest {
assertTrue(message.contains("source.folder: must not be null")); assertTrue(message.contains("source.folder: must not be null"));
assertTrue(message.contains("target.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("sqlite.file: must not be null"));
assertTrue(message.contains("api.baseUrl: must not be null")); assertTrue(message.contains("ai provider configuration: must not be null"));
assertTrue(message.contains("api.model: must not be null or blank"));
assertTrue(message.contains("prompt.template.file: 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.retries.transient: must be >= 1"));
assertTrue(message.contains("max.pages: must be > 0")); assertTrue(message.contains("max.pages: must be > 0"));
assertTrue(message.contains("max.text.characters: must be > 0")); assertTrue(message.contains("max.text.characters: must be > 0"));
} }
/**
* Focused tests for source folder validation using mocked filesystem checks.
* <p>
* These tests verify the four critical paths for source folder validation without
* relying on platform-dependent filesystem permissions or the actual FS state.
*/
@Test @Test
void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception { void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception {
Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
@@ -852,9 +655,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("nonexistent"), tempDir.resolve("nonexistent"),
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -862,11 +663,9 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
// Mock: always return "does not exist" error for any path
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path does not exist: " + path; "- source.folder: path does not exist: " + path;
@@ -889,9 +688,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"), tempDir.resolve("somepath"),
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -899,11 +696,9 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
// Mock: simulate path exists but is not a directory
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path is not a directory: " + path; "- source.folder: path is not a directory: " + path;
@@ -926,9 +721,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"), tempDir.resolve("somepath"),
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -936,12 +729,9 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false 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 -> StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: directory is not readable: " + path; "- source.folder: directory is not readable: " + path;
@@ -965,9 +755,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -975,11 +763,9 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
// Mock: all checks pass (return null)
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null; StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null;
StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker); StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker);
@@ -988,24 +774,19 @@ class StartConfigurationValidatorTest {
"Validation should succeed when source folder checker returns null"); "Validation should succeed when source folder checker returns null");
} }
// Neue Tests zur Verbesserung der Abdeckung
@Test @Test
void validate_failsWhenSqliteFileHasNoParent() throws Exception { void validate_failsWhenSqliteFileHasNoParent() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
// Ein Pfad ohne Parent (z.B. einfacher Dateiname)
Path sqliteFileWithoutParent = Path.of("db.sqlite"); Path sqliteFileWithoutParent = Path.of("db.sqlite");
StartConfiguration config = new StartConfiguration( StartConfiguration config = new StartConfiguration(
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFileWithoutParent, sqliteFileWithoutParent,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -1013,7 +794,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -1030,7 +810,6 @@ class StartConfigurationValidatorTest {
Path targetFolder = Files.createDirectory(tempDir.resolve("target")); Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); 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 parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite"); Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite");
@@ -1038,9 +817,7 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFileWithFileAsParent, sqliteFileWithFileAsParent,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -1048,7 +825,6 @@ class StartConfigurationValidatorTest {
null, null,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -1059,70 +835,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("sqlite.file: parent is not a directory")); 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 @Test
void validate_runtimeLockFileParentDoesNotExist() throws Exception { void validate_runtimeLockFileParentDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -1134,17 +846,14 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
promptTemplateFile, promptTemplateFile,
tempDir.resolve("nonexistent/lock.lock"), // Lock file mit nicht existierendem Parent tempDir.resolve("nonexistent/lock.lock"),
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -1162,7 +871,6 @@ class StartConfigurationValidatorTest {
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); 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 parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path lockFileWithFileAsParent = parentFile.resolve("lock.lock"); Path lockFileWithFileAsParent = parentFile.resolve("lock.lock");
@@ -1170,17 +878,14 @@ class StartConfigurationValidatorTest {
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
promptTemplateFile, promptTemplateFile,
lockFileWithFileAsParent, // Lock file mit Datei als Parent lockFileWithFileAsParent,
null, null,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -1198,24 +903,20 @@ class StartConfigurationValidatorTest {
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); 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")); Path logFileInsteadOfDirectory = Files.createFile(tempDir.resolve("logfile.txt"));
StartConfiguration config = new StartConfiguration( StartConfiguration config = new StartConfiguration(
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
promptTemplateFile, promptTemplateFile,
null, null,
logFileInsteadOfDirectory, // Datei statt Verzeichnis logFileInsteadOfDirectory,
"INFO", "INFO",
"test-api-key",
false false
); );
@@ -1225,66 +926,4 @@ class StartConfigurationValidatorTest {
); );
assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory")); 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"));
}
} }

View File

@@ -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}.
* <p>
* 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)");
}
}

View File

@@ -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.
* <p>
* Covers all mandatory test cases for the new configuration schema as defined
* in the active work package specification.
*/
class MultiProviderConfigurationTest {
private static final Function<String, String> 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<String, String> 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.
* <p>
* 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<String, String> 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<String, String> 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<String, String> 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");
}
}

View File

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

View File

@@ -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}.
* <p>
* 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<ProcessingAttempt> 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<ProcessingAttempt> 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}.
* <p>
* 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<ProcessingAttempt> 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<ProcessingAttempt> 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<ProcessingAttempt> 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<ProcessingAttempt> 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));
}
}

View File

@@ -391,6 +391,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, startedAt, endedAt, fingerprint, runId, 1, startedAt, endedAt,
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
"openai-compatible",
"gpt-4o", "prompt-v1.txt", "gpt-4o", "prompt-v1.txt",
5, 1234, 5, 1234,
"{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}", "{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
@@ -434,6 +435,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(5), fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
"openai-compatible",
"claude-sonnet-4-6", "prompt-v2.txt", "claude-sonnet-4-6", "prompt-v2.txt",
3, 800, 3, 800,
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}", "{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
@@ -531,6 +533,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2), fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"gpt-4o", "prompt-v1.txt", 2, 500, "gpt-4o", "prompt-v1.txt", 2, 500,
"{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}", "{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
"Found.", date, DateSource.AI_PROVIDED, "Rechnung", "Found.", date, DateSource.AI_PROVIDED, "Rechnung",
@@ -560,6 +563,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1), fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"model-a", "prompt-v1.txt", 1, 100, "model-a", "prompt-v1.txt", 1, 100,
"{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins", "{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
null null
@@ -577,6 +581,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21), fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"model-b", "prompt-v2.txt", 2, 200, "model-b", "prompt-v2.txt", 2, 200,
"{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei", "{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
null null
@@ -606,6 +611,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(3), fingerprint, runId, 1, now, now.plusSeconds(3),
ProcessingStatus.SUCCESS, ProcessingStatus.SUCCESS,
null, null, false, null, null, false,
null,
"gpt-4", "prompt-v1.txt", 2, 600, "gpt-4", "prompt-v1.txt", 2, 600,
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}", "{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
"Invoice.", "Invoice.",
@@ -637,6 +643,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1), fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"gpt-4", "prompt-v1.txt", 1, 200, "gpt-4", "prompt-v1.txt", 1, 200,
"{}", "reason", "{}", "reason",
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED, LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
@@ -667,6 +674,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2), fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"model-a", "prompt-v1.txt", 3, 700, "model-a", "prompt-v1.txt", 3, 700,
"{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null "{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
); );
@@ -679,7 +687,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
ProcessingStatus.SUCCESS, ProcessingStatus.SUCCESS,
null, null, false, null, null, false,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null,
"2026-02-10 - Bescheid.pdf" "2026-02-10 - Bescheid.pdf"
); );
repository.save(successAttempt); repository.save(successAttempt);
@@ -742,6 +750,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1), fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"model-x", "prompt-v1.txt", 1, 50, "model-x", "prompt-v1.txt", 1, 50,
"{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel", "{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
null null
@@ -787,6 +796,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(5), fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
null,
"gpt-4o", "prompt-v1.txt", "gpt-4o", "prompt-v1.txt",
3, 750, 3, 750,
fullRawResponse, fullRawResponse,

View File

@@ -119,7 +119,8 @@ class SqliteSchemaInitializationAdapterTest {
"resolved_date", "resolved_date",
"date_source", "date_source",
"validated_title", "validated_title",
"final_target_file_name" "final_target_file_name",
"ai_provider"
); );
} }

View File

@@ -1,11 +1,12 @@
source.folder=/tmp/source source.folder=/tmp/source
target.folder=/tmp/target target.folder=/tmp/target
# sqlite.file is missing # sqlite.file is missing
api.baseUrl=https://api.example.com ai.provider.active=openai-compatible
api.model=gpt-4 ai.provider.openai-compatible.baseUrl=https://api.example.com
api.timeoutSeconds=30 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.retries.transient=3
max.pages=100 max.pages=100
max.text.characters=50000 max.text.characters=50000
prompt.template.file=/tmp/prompt.txt prompt.template.file=/tmp/prompt.txt
api.key=test-api-key

View File

@@ -1,9 +1,10 @@
source.folder=/tmp/source source.folder=/tmp/source
target.folder=/tmp/target target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com ai.provider.active=openai-compatible
api.model=gpt-4 ai.provider.openai-compatible.baseUrl=https://api.example.com
api.timeoutSeconds=30 ai.provider.openai-compatible.model=gpt-4
ai.provider.openai-compatible.timeoutSeconds=30
max.retries.transient=3 max.retries.transient=3
max.pages=100 max.pages=100
max.text.characters=50000 max.text.characters=50000

View File

@@ -1,9 +1,11 @@
source.folder=/tmp/source source.folder=/tmp/source
target.folder=/tmp/target target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com ai.provider.active=openai-compatible
api.model=gpt-4 ai.provider.openai-compatible.baseUrl=https://api.example.com
api.timeoutSeconds=30 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.retries.transient=3
max.pages=100 max.pages=100
max.text.characters=50000 max.text.characters=50000
@@ -11,4 +13,3 @@ prompt.template.file=/tmp/prompt.txt
runtime.lock.file=/tmp/lock.lock runtime.lock.file=/tmp/lock.lock
log.directory=/tmp/logs log.directory=/tmp/logs
log.level=DEBUG log.level=DEBUG
api.key=test-api-key-from-properties

View File

@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<AiProviderFamily> fromIdentifier(String identifier) {
if (identifier == null) {
return Optional.empty();
}
return Arrays.stream(values())
.filter(f -> f.identifier.equals(identifier))
.findFirst();
}
}

View File

@@ -0,0 +1,43 @@
package de.gecheckt.pdf.umbenenner.application.config.provider;
/**
* Immutable multi-provider configuration model.
* <p>
* 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.
*
* <h2>Invariants</h2>
* <ul>
* <li>Exactly one provider family is active per run.</li>
* <li>Required fields are enforced only for the active provider; the inactive
* provider's configuration may be incomplete.</li>
* <li>Validation of these invariants is performed by the corresponding validator
* in the adapter layer, not by this record itself.</li>
* </ul>
*
* @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;
};
}
}

View File

@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.application.config.provider;
/**
* Immutable configuration for a single AI provider family.
* <p>
* 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.
*
* <h2>Field semantics</h2>
* <ul>
* <li>{@code model} the AI model name; required for the active provider, may be {@code null}
* for the inactive provider.</li>
* <li>{@code timeoutSeconds} HTTP connection/read timeout in seconds; must be positive for
* the active provider. {@code 0} indicates the value was not configured.</li>
* <li>{@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}.</li>
* <li>{@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.</li>
* </ul>
*
* @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) {
}

View File

@@ -1,16 +1,24 @@
package de.gecheckt.pdf.umbenenner.application.config.startup; package de.gecheckt.pdf.umbenenner.application.config.startup;
import java.net.URI;
import java.nio.file.Path; import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
/** /**
* Typed immutable configuration model for PDF Umbenenner startup parameters. * Typed immutable configuration model for PDF Umbenenner startup parameters.
* <p> * <p>
* Contains all technical infrastructure and runtime configuration parameters * Contains all technical infrastructure and runtime configuration parameters
* loaded and validated at bootstrap time. This is a complete configuration model * 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. * and operational parameters.
* *
* <h2>AI provider configuration</h2>
* <p>
* 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.
*
* <h2>AI content sensitivity ({@code log.ai.sensitive})</h2> * <h2>AI content sensitivity ({@code log.ai.sensitive})</h2>
* <p> * <p>
* The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated * The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated
@@ -25,9 +33,7 @@ public record StartConfiguration(
Path sourceFolder, Path sourceFolder,
Path targetFolder, Path targetFolder,
Path sqliteFile, Path sqliteFile,
URI apiBaseUrl, MultiProviderConfiguration multiProviderConfiguration,
String apiModel,
int apiTimeoutSeconds,
int maxRetriesTransient, int maxRetriesTransient,
int maxPages, int maxPages,
int maxTextCharacters, int maxTextCharacters,
@@ -35,7 +41,6 @@ public record StartConfiguration(
Path runtimeLockFile, Path runtimeLockFile,
Path logDirectory, Path logDirectory,
String logLevel, String logLevel,
String apiKey,
/** /**
* Whether sensitive AI content (raw response, reasoning) may be written to log files. * Whether sensitive AI content (raw response, reasoning) may be written to log files.

View File

@@ -42,6 +42,10 @@ import java.util.Objects;
* successful or skip attempts.</li> * successful or skip attempts.</li>
* <li>{@link #retryable()} — {@code true} if the failure is considered retryable in a * <li>{@link #retryable()} — {@code true} if the failure is considered retryable in a
* later run; {@code false} for final failures, successes, and skip attempts.</li> * later run; {@code false} for final failures, successes, and skip attempts.</li>
* <li>{@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.</li>
* <li>{@link #modelName()} — the AI model name used in this attempt; {@code null} if * <li>{@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).</li> * no AI call was made (e.g. pre-check failures or skip attempts).</li>
* <li>{@link #promptIdentifier()} — stable identifier of the prompt template used; * <li>{@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 failureClass failure classification, or {@code null} for non-failure statuses
* @param failureMessage failure description, 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 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 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 promptIdentifier prompt identifier, or {@code null} if no AI call was made
* @param processedPageCount number of PDF pages processed, or {@code null} * @param processedPageCount number of PDF pages processed, or {@code null}
@@ -97,6 +102,7 @@ public record ProcessingAttempt(
String failureMessage, String failureMessage,
boolean retryable, boolean retryable,
// AI traceability fields (null for non-AI attempts) // AI traceability fields (null for non-AI attempts)
String aiProvider,
String modelName, String modelName,
String promptIdentifier, String promptIdentifier,
Integer processedPageCount, Integer processedPageCount,
@@ -131,7 +137,8 @@ public record ProcessingAttempt(
* Creates a {@link ProcessingAttempt} with no AI traceability fields set. * Creates a {@link ProcessingAttempt} with no AI traceability fields set.
* <p> * <p>
* Convenience factory for pre-check failures, skip events, and any attempt * 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 fingerprint document identity; must not be null
* @param runId batch run identifier; must not be null * @param runId batch run identifier; must not be null
@@ -157,6 +164,6 @@ public record ProcessingAttempt(
return new ProcessingAttempt( return new ProcessingAttempt(
fingerprint, runId, attemptNumber, startedAt, endedAt, fingerprint, runId, attemptNumber, startedAt, endedAt,
status, failureClass, failureMessage, retryable, 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);
} }
} }

View File

@@ -154,15 +154,22 @@ public class DocumentProcessingCoordinator {
private final TargetFileCopyPort targetFileCopyPort; private final TargetFileCopyPort targetFileCopyPort;
private final ProcessingLogger logger; private final ProcessingLogger logger;
private final int maxRetriesTransient; private final int maxRetriesTransient;
private final String activeProviderIdentifier;
/** /**
* Creates the document processing coordinator with all required ports, logger, and * Creates the document processing coordinator with all required ports, logger,
* the transient retry limit. * the transient retry limit, and the active AI provider identifier.
* <p> * <p>
* {@code maxRetriesTransient} is the maximum number of historised transient error attempts * {@code maxRetriesTransient} is the maximum number of historised transient error attempts
* per fingerprint before the document is finalised to * per fingerprint before the document is finalised to
* {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to * {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to
* reach this value finalises the document. Must be &gt;= 1. * reach this value finalises the document. Must be &gt;= 1.
* <p>
* {@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; * @param documentRecordRepository port for reading and writing the document master record;
* must not be null * must not be null
@@ -176,8 +183,11 @@ public class DocumentProcessingCoordinator {
* @param logger for processing-related logging; must not be null * @param logger for processing-related logging; must not be null
* @param maxRetriesTransient maximum number of historised transient error attempts * @param maxRetriesTransient maximum number of historised transient error attempts
* before finalisation; must be &gt;= 1 * before finalisation; must be &gt;= 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 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( public DocumentProcessingCoordinator(
DocumentRecordRepository documentRecordRepository, DocumentRecordRepository documentRecordRepository,
@@ -186,11 +196,16 @@ public class DocumentProcessingCoordinator {
TargetFolderPort targetFolderPort, TargetFolderPort targetFolderPort,
TargetFileCopyPort targetFileCopyPort, TargetFileCopyPort targetFileCopyPort,
ProcessingLogger logger, ProcessingLogger logger,
int maxRetriesTransient) { int maxRetriesTransient,
String activeProviderIdentifier) {
if (maxRetriesTransient < 1) { if (maxRetriesTransient < 1) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient); "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 = this.documentRecordRepository =
Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null"); Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
this.processingAttemptRepository = this.processingAttemptRepository =
@@ -203,6 +218,7 @@ public class DocumentProcessingCoordinator {
Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null"); Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
this.logger = Objects.requireNonNull(logger, "logger must not be null"); this.logger = Objects.requireNonNull(logger, "logger must not be null");
this.maxRetriesTransient = maxRetriesTransient; this.maxRetriesTransient = maxRetriesTransient;
this.activeProviderIdentifier = activeProviderIdentifier;
} }
/** /**
@@ -503,7 +519,7 @@ public class DocumentProcessingCoordinator {
ProcessingAttempt successAttempt = new ProcessingAttempt( ProcessingAttempt successAttempt = new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, attemptStart, now, fingerprint, context.runId(), attemptNumber, attemptStart, now,
ProcessingStatus.SUCCESS, null, null, false, 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); resolvedFilename);
DocumentRecord successRecord = buildSuccessRecord( DocumentRecord successRecord = buildSuccessRecord(
@@ -951,6 +967,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt( yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt, fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(), ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(), ctx.aiRawResponse(),
@@ -964,6 +981,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt( yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt, fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(), ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(), ctx.aiRawResponse(),
@@ -976,6 +994,7 @@ public class DocumentProcessingCoordinator {
yield new ProcessingAttempt( yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt, fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(), outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
activeProviderIdentifier,
ctx.modelName(), ctx.promptIdentifier(), ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(), ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(), ctx.aiRawResponse(),

View File

@@ -90,7 +90,7 @@ class DocumentProcessingCoordinatorTest {
unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo); unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
candidate = new SourceDocumentCandidate( candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); "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 // With maxRetriesTransient=1, the very first transient error finalises the document
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1); new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1,
"openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new TechnicalDocumentError( DocumentProcessingOutcome outcome = new TechnicalDocumentError(
@@ -668,7 +669,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null)); recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -686,7 +687,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -705,7 +706,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, 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)); DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed( DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -724,7 +725,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -742,7 +743,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
unitOfWorkPort.failOnExecute = true; unitOfWorkPort.failOnExecute = true;
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -761,7 +762,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -780,7 +781,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
unitOfWorkPort.failOnExecute = true; unitOfWorkPort.failOnExecute = true;
@@ -848,6 +849,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt( ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
null,
"model", "prompt", 1, 100, "{}", "reason", "model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null); null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal); attemptRepo.savedAttempts.add(badProposal);
@@ -871,7 +873,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithFailingFolder.processDeferredOutcome( boolean result = coordinatorWithFailingFolder.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null); candidate, fingerprint, context, attemptStart, c -> null);
@@ -893,7 +895,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(), new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithFailingCopy.processDeferredOutcome( boolean result = coordinatorWithFailingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null); candidate, fingerprint, context, attemptStart, c -> null);
@@ -915,6 +917,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt( ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
null,
"model", "prompt", 1, 100, "{}", "reason", "model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"A".repeat(21), null); "A".repeat(21), null);
@@ -941,6 +944,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt( ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
null,
"model", "prompt", 1, 100, "{}", "reason", "model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"Rechnung-2026", null); "Rechnung-2026", null);
@@ -980,7 +984,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithCountingCopy.processDeferredOutcome( boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> { candidate, fingerprint, context, attemptStart, c -> {
@@ -1014,7 +1018,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
boolean result = coordinatorWithCountingCopy.processDeferredOutcome( boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null); candidate, fingerprint, context, attemptStart, c -> null);
@@ -1044,7 +1048,8 @@ class DocumentProcessingCoordinatorTest {
CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1); new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1,
"openai-compatible");
boolean result = coordinatorWith1Retry.processDeferredOutcome( boolean result = coordinatorWith1Retry.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null); candidate, fingerprint, context, attemptStart, c -> null);
@@ -1079,7 +1084,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1105,7 +1110,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger, 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); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1128,7 +1133,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(), new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCountingCopy.processDeferredOutcome( coordinatorWithCountingCopy.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, candidate, fingerprint, context, attemptStart,
@@ -1197,7 +1202,8 @@ class DocumentProcessingCoordinatorTest {
// maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL // maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, 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); DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null);
// Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1 // Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1
@@ -1233,6 +1239,7 @@ class DocumentProcessingCoordinatorTest {
return new ProcessingAttempt( return new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
"openai-compatible",
"gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason", "gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null); LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
} }
@@ -1495,7 +1502,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentTerminalSuccess( recordRepo.setLookupResult(new DocumentTerminalSuccess(
buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()))); buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero())));
@@ -1516,7 +1523,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentTerminalFinalFailure( recordRepo.setLookupResult(new DocumentTerminalFinalFailure(
buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)))); buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0))));
@@ -1537,7 +1544,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
coordinatorWithCapturing.process(candidate, fingerprint, coordinatorWithCapturing.process(candidate, fingerprint,
@@ -1560,7 +1567,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = DocumentProcessingCoordinator coordinatorWithCapturing =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, 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 // Existing record already has one content error — second content error finalises
recordRepo.setLookupResult(new DocumentKnownProcessable( recordRepo.setLookupResult(new DocumentKnownProcessable(
buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0)))); buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0))));
@@ -1596,7 +1603,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1612,6 +1619,7 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt badProposal = new ProcessingAttempt( ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
null,
"model", "prompt", 1, 100, "{}", "reason", "model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null); null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal); attemptRepo.savedAttempts.add(badProposal);
@@ -1620,7 +1628,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1639,7 +1647,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1658,7 +1666,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome( coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, candidate, fingerprint, context, attemptStart,
@@ -1680,7 +1688,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome( coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, candidate, fingerprint, context, attemptStart,
@@ -1702,7 +1710,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), bothFail, capturingLogger, new NoOpTargetFolderPort(), bothFail, capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome( coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, c -> null); candidate, fingerprint, context, attemptStart, c -> null);
@@ -1723,7 +1731,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger, new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome( coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, candidate, fingerprint, context, attemptStart,
@@ -1843,7 +1851,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome( coordinatorWithCapturing.processDeferredOutcome(
candidate, fingerprint, context, attemptStart, candidate, fingerprint, context, attemptStart,
@@ -1873,7 +1881,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -1897,7 +1905,7 @@ class DocumentProcessingCoordinatorTest {
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator( DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort, recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger, new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
DEFAULT_MAX_RETRIES_TRANSIENT); DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);

View File

@@ -356,6 +356,7 @@ class TargetFilenameBuildingServiceTest {
Instant.now(), Instant.now(), Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, ProcessingStatus.PROPOSAL_READY,
null, null, false, null, null, false,
"openai-compatible",
"gpt-4", "prompt-v1.txt", 1, 100, "gpt-4", "prompt-v1.txt", 1, 100,
"{}", "reasoning text", "{}", "reasoning text",
date, DateSource.AI_PROVIDED, title, date, DateSource.AI_PROVIDED, title,

View File

@@ -469,7 +469,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator( DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger(), 3) { new NoOpProcessingLogger(), 3, "openai-compatible") {
@Override @Override
public boolean processDeferredOutcome( public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
@@ -517,7 +517,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator( DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger(), 3) { new NoOpProcessingLogger(), 3, "openai-compatible") {
private int callCount = 0; private int callCount = 0;
@Override @Override
@@ -760,7 +760,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork, 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 // Fingerprint port returns the pre-defined fingerprint for this candidate
FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint); FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
@@ -807,7 +808,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork, 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); FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
@@ -860,7 +862,8 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator( DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWork, recordRepo, attemptRepo, unitOfWork,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
"openai-compatible");
FingerprintPort perCandidateFingerprintPort = candidate -> { FingerprintPort perCandidateFingerprintPort = candidate -> {
if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp); if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp);
@@ -1152,7 +1155,8 @@ class BatchRunProcessingUseCaseTest {
private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator { private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
NoOpDocumentProcessingCoordinator() { NoOpDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), 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() { TrackingDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3); new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
"openai-compatible");
} }
@Override @Override

View File

@@ -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.
* <p>
* 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}.
*
* <h2>Registered providers</h2>
* <ul>
* <li>{@link AiProviderFamily#OPENAI_COMPATIBLE} — {@link OpenAiHttpAdapter}</li>
* <li>{@link AiProviderFamily#CLAUDE} — {@link AnthropicClaudeHttpAdapter}</li>
* </ul>
*
* <h2>Hard start failure</h2>
* <p>
* 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");
}
}

View File

@@ -9,11 +9,11 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; 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.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; 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.clock.SystemClockAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException; 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.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter; 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.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.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; 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.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; 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 is handed to the use case factory which extracts the minimal runtime
* configuration for the application layer. * configuration for the application layer.
* *
* <h2>Active AI provider</h2>
* <p>
* 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.
*
* <h2>Exit code semantics</h2> * <h2>Exit code semantics</h2>
* <ul> * <ul>
* <li>{@code 0}: Batch run executed successfully; individual document failures do not * <li>{@code 0}: Batch run executed successfully; individual document failures do not
@@ -82,10 +90,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* <p> * <p>
* The production constructor wires the following key adapters: * The production constructor wires the following key adapters:
* <ul> * <ul>
* <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from properties and environment.</li> * <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from the multi-provider
* properties schema and environment.</li>
* <li>{@link AiProviderSelector} — selects the active {@link AiInvocationPort} implementation
* based on {@code ai.provider.active}.</li>
* <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li> * <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li>
* <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema (including target-copy * <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.</li>
* schema evolution) at startup.</li>
* <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li> * <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
@@ -103,6 +113,7 @@ public class BootstrapRunner {
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class); private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
private final MigrationStep migrationStep;
private final ConfigurationPortFactory configPortFactory; private final ConfigurationPortFactory configPortFactory;
private final RunLockPortFactory runLockPortFactory; private final RunLockPortFactory runLockPortFactory;
private final ValidatorFactory validatorFactory; private final ValidatorFactory validatorFactory;
@@ -110,6 +121,19 @@ public class BootstrapRunner {
private final UseCaseFactory useCaseFactory; private final UseCaseFactory useCaseFactory;
private final CommandFactory commandFactory; private final CommandFactory commandFactory;
/**
* Functional interface encapsulating the legacy configuration migration step.
* <p>
* 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. * Functional interface for creating a ConfigurationPort.
*/ */
@@ -175,12 +199,12 @@ public class BootstrapRunner {
* Wires the processing pipeline with the following adapters: * Wires the processing pipeline with the following adapters:
* <ul> * <ul>
* <li>{@link PropertiesConfigurationPortAdapter} for configuration loading.</li> * <li>{@link PropertiesConfigurationPortAdapter} for configuration loading.</li>
* <li>{@link AiProviderSelector} for selecting the active AI provider implementation.</li>
* <li>{@link FilesystemRunLockPortAdapter} for exclusive run locking.</li> * <li>{@link FilesystemRunLockPortAdapter} for exclusive run locking.</li>
* <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li> * <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li>
* <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li> * <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li>
* <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li> * <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL and target-copy schema * <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.</li>
* evolution at startup.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li> * <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
@@ -199,6 +223,8 @@ public class BootstrapRunner {
* begins. Failure during initialisation aborts the run with exit code 1. * begins. Failure during initialisation aborts the run with exit code 1.
*/ */
public BootstrapRunner() { public BootstrapRunner() {
this.migrationStep = () -> new LegacyConfigurationMigrator()
.migrateIfLegacy(Paths.get("config/application.properties"));
this.configPortFactory = PropertiesConfigurationPortAdapter::new; this.configPortFactory = PropertiesConfigurationPortAdapter::new;
this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new; this.validatorFactory = StartConfigurationValidator::new;
@@ -206,7 +232,13 @@ public class BootstrapRunner {
this.useCaseFactory = (startConfig, lock) -> { this.useCaseFactory = (startConfig, lock) -> {
// Extract runtime configuration from startup configuration // Extract runtime configuration from startup configuration
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); 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); String jdbcUrl = buildJdbcUrl(startConfig);
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
@@ -216,17 +248,18 @@ public class BootstrapRunner {
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl); new SqliteUnitOfWorkAdapter(jdbcUrl);
// Wire coordinators logger with AI content sensitivity setting // Wire coordinator logger with AI content sensitivity setting
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class, aiContentSensitivity); ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
DocumentProcessingCoordinator.class, aiContentSensitivity);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder()); TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
DocumentProcessingCoordinator documentProcessingCoordinator = DocumentProcessingCoordinator documentProcessingCoordinator =
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger, unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
startConfig.maxRetriesTransient()); startConfig.maxRetriesTransient(),
activeFamily.getIdentifier());
// Wire AI naming pipeline // Wire AI naming pipeline
AiInvocationPort aiInvocationPort = new OpenAiHttpAdapter(startConfig);
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter(); ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
@@ -234,11 +267,12 @@ public class BootstrapRunner {
aiInvocationPort, aiInvocationPort,
promptPort, promptPort,
aiResponseValidator, aiResponseValidator,
startConfig.apiModel(), providerConfig.model(),
startConfig.maxTextCharacters()); startConfig.maxTextCharacters());
// Wire use case logger with AI content sensitivity setting // 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( return new DefaultBatchRunProcessingUseCase(
runtimeConfig, runtimeConfig,
lock, lock,
@@ -254,6 +288,9 @@ public class BootstrapRunner {
/** /**
* Creates the BootstrapRunner with custom factories for testing. * Creates the BootstrapRunner with custom factories for testing.
* <p>
* 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 configPortFactory factory for creating ConfigurationPort instances
* @param runLockPortFactory factory for creating RunLockPort instances * @param runLockPortFactory factory for creating RunLockPort instances
@@ -268,6 +305,32 @@ public class BootstrapRunner {
SchemaInitializationPortFactory schemaInitPortFactory, SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory, UseCaseFactory useCaseFactory,
CommandFactory commandFactory) { 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.
* <p>
* 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.configPortFactory = configPortFactory;
this.runLockPortFactory = runLockPortFactory; this.runLockPortFactory = runLockPortFactory;
this.validatorFactory = validatorFactory; this.validatorFactory = validatorFactory;
@@ -299,6 +362,7 @@ public class BootstrapRunner {
LOG.info("Bootstrap flow started."); LOG.info("Bootstrap flow started.");
try { try {
// Bootstrap Phase: prepare configuration and persistence // Bootstrap Phase: prepare configuration and persistence
migrateConfigurationIfNeeded();
StartConfiguration config = loadAndValidateConfiguration(); StartConfiguration config = loadAndValidateConfiguration();
initializeSchema(config); initializeSchema(config);
// Execution Phase: run batch processing // Execution Phase: run batch processing
@@ -318,6 +382,20 @@ public class BootstrapRunner {
} }
} }
/**
* Runs the legacy configuration migration step exactly once before configuration loading.
* <p>
* 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.
* <p>
* 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 * Loads configuration via {@link ConfigurationPort} and validates it via
* {@link StartConfigurationValidator}. * {@link StartConfigurationValidator}.
@@ -329,13 +407,17 @@ public class BootstrapRunner {
* creatable (validator attempts {@code Files.createDirectories} if absent; * creatable (validator attempts {@code Files.createDirectories} if absent;
* failure here is a hard startup error).</li> * failure here is a hard startup error).</li>
* <li>{@code sqlite.file}: parent directory must exist.</li> * <li>{@code sqlite.file}: parent directory must exist.</li>
* <li>All numeric and URI constraints.</li> * <li>All numeric and path constraints.</li>
* </ul> * </ul>
* <p>
* After successful validation, the active AI provider identifier is logged at INFO level.
*/ */
private StartConfiguration loadAndValidateConfiguration() { private StartConfiguration loadAndValidateConfiguration() {
ConfigurationPort configPort = configPortFactory.create(); ConfigurationPort configPort = configPortFactory.create();
StartConfiguration config = configPort.loadConfiguration(); StartConfiguration config = configPort.loadConfiguration();
validatorFactory.create().validate(config); validatorFactory.create().validate(config);
LOG.info("Active AI provider: {}",
config.multiProviderConfiguration().activeProviderFamily().getIdentifier());
return config; return config;
} }

View File

@@ -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}.
* <p>
* 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.
* <p>
* 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");
}
}

View File

@@ -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.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; 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.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.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -51,9 +52,7 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")), Files.createDirectories(tempDir.resolve("target")),
Files.createFile(tempDir.resolve("db.sqlite")), Files.createFile(tempDir.resolve("db.sqlite")),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -61,7 +60,6 @@ class BootstrapRunnerEdgeCasesTest {
null, // null runtimeLockFile null, // null runtimeLockFile
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-key",
false false
); );
@@ -101,14 +99,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")), Files.createDirectories(tempDir.resolve("target")),
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4", 3, 100, 50000,
30, 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")), Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-key",
false false
); );
@@ -128,14 +124,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")), Files.createDirectories(tempDir.resolve("target")),
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4", 3, 100, 50000,
30, 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")), Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-key",
false false
); );
@@ -157,13 +151,12 @@ class BootstrapRunnerEdgeCasesTest {
Files.createDirectories(tempDir.resolve("source")), Files.createDirectories(tempDir.resolve("source")),
Files.createDirectories(tempDir.resolve("target")), Files.createDirectories(tempDir.resolve("target")),
Files.createFile(tempDir.resolve("db.sqlite")), Files.createFile(tempDir.resolve("db.sqlite")),
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4", 30, 3, 100, 50000, 3, 100, 50000,
Files.createFile(tempDir.resolve("prompt.txt")), Files.createFile(tempDir.resolve("prompt.txt")),
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-key",
false false
); );
@@ -226,9 +219,9 @@ class BootstrapRunnerEdgeCasesTest {
Path dbFile = Files.createFile(tempDir.resolve("db.sqlite")); Path dbFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptFile = Files.createFile(tempDir.resolve("prompt.txt")); Path promptFile = Files.createFile(tempDir.resolve("prompt.txt"));
return new StartConfiguration(sourceDir, targetDir, dbFile, 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"), promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"),
"INFO", "key", false); "INFO", false);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
@@ -342,9 +335,19 @@ class BootstrapRunnerEdgeCasesTest {
"logAiSensitive=true must resolve to LOG_SENSITIVE_CONTENT"); "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 // Mocks
// ========================================================================= // -------------------------------------------------------------------------
private static class MockConfigurationPort implements ConfigurationPort { private static class MockConfigurationPort implements ConfigurationPort {
private final Path tempDir; private final Path tempDir;
@@ -373,13 +376,16 @@ class BootstrapRunnerEdgeCasesTest {
Files.createFile(promptTemplateFile); 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( return new StartConfiguration(
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), multiConfig,
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -387,7 +393,6 @@ class BootstrapRunnerEdgeCasesTest {
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-api-key",
false false
); );
} catch (Exception e) { } catch (Exception e) {

View File

@@ -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.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; 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.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.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; 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.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -176,9 +189,7 @@ class BootstrapRunnerTest {
sourceDir, sourceDir,
targetDir, targetDir,
dbFile, dbFile,
URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -186,7 +197,6 @@ class BootstrapRunnerTest {
Paths.get(""), // empty simulates unconfigured runtime.lock.file Paths.get(""), // empty simulates unconfigured runtime.lock.file
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-key",
false false
); );
@@ -262,9 +272,7 @@ class BootstrapRunnerTest {
sourceDir, sourceDir,
targetDir, targetDir,
dbFile, dbFile,
java.net.URI.create("https://api.example.com"), validMultiProviderConfig(),
"gpt-4",
30,
0, // max.retries.transient = 0 is invalid (must be >= 1) 0, // max.retries.transient = 0 is invalid (must be >= 1)
100, 100,
50000, 50000,
@@ -272,7 +280,6 @@ class BootstrapRunnerTest {
tempDir.resolve("lock-mrt.lock"), tempDir.resolve("lock-mrt.lock"),
null, null,
"INFO", "INFO",
"test-key",
false false
); );
@@ -346,6 +353,121 @@ class BootstrapRunnerTest {
assertEquals(1, exitCode, "Schema initialization failure should return exit code 1"); 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<String> 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.
* <p>
* 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 // Mocks
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -377,13 +499,16 @@ class BootstrapRunnerTest {
Files.createFile(promptTemplateFile); 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( return new StartConfiguration(
sourceFolder, sourceFolder,
targetFolder, targetFolder,
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), multiConfig,
"gpt-4",
30,
3, 3,
100, 100,
50000, 50000,
@@ -391,7 +516,6 @@ class BootstrapRunnerTest {
tempDir.resolve("lock.lock"), tempDir.resolve("lock.lock"),
tempDir.resolve("logs"), tempDir.resolve("logs"),
"INFO", "INFO",
"test-api-key",
false false
); );
} catch (Exception e) { } catch (Exception e) {

View File

@@ -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.
* <p>
* 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.
*
* <h2>What is verified</h2>
* <ul>
* <li>When {@code ai.provider.active=openai-compatible}, the {@link AiProviderSelector}
* produces an {@link OpenAiHttpAdapter} instance.</li>
* <li>When {@code ai.provider.active=claude}, the {@link AiProviderSelector}
* produces an {@link AnthropicClaudeHttpAdapter} instance.</li>
* </ul>
*
* <h2>Scope</h2>
* <p>
* 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.
* <p>
* 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<AiInvocationPort> 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.
* <p>
* 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<AiInvocationPort> 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() { }
}
}

View File

@@ -52,15 +52,16 @@ class ExecutableJarSmokeTestIT {
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt")); Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
Files.writeString(promptTemplateFile, "Test prompt template for smoke test."); Files.writeString(promptTemplateFile, "Test prompt template for smoke test.");
// Write valid application.properties
Path configFile = configDir.resolve("application.properties"); Path configFile = configDir.resolve("application.properties");
String validConfig = """ String validConfig = """
source.folder=%s source.folder=%s
target.folder=%s target.folder=%s
sqlite.file=%s sqlite.file=%s
api.baseUrl=http://localhost:8080/api ai.provider.active=openai-compatible
api.model=gpt-4o-mini ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
api.timeoutSeconds=30 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.retries.transient=3
max.pages=10 max.pages=10
max.text.characters=5000 max.text.characters=5000
@@ -68,7 +69,6 @@ class ExecutableJarSmokeTestIT {
runtime.lock.file=%s/lock.pid runtime.lock.file=%s/lock.pid
log.directory=%s log.directory=%s
log.level=INFO log.level=INFO
api.key=test-api-key-for-smoke-test
""".formatted( """.formatted(
sourceDir.toAbsolutePath(), sourceDir.toAbsolutePath(),
targetDir.toAbsolutePath(), targetDir.toAbsolutePath(),
@@ -185,16 +185,17 @@ class ExecutableJarSmokeTestIT {
source.folder=%s source.folder=%s
# target.folder is intentionally missing - should cause validation failure # target.folder is intentionally missing - should cause validation failure
sqlite.file=%s sqlite.file=%s
api.baseUrl=http://localhost:8080/api ai.provider.active=openai-compatible
api.model=gpt-4o-mini ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
api.timeoutSeconds=30 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.retries.transient=3
max.pages=10 max.pages=10
max.text.characters=5000 max.text.characters=5000
prompt.template.file=%s prompt.template.file=%s
log.directory=%s/logs log.directory=%s/logs
log.level=INFO log.level=INFO
api.key=test-api-key
""".formatted( """.formatted(
sourceDir.toAbsolutePath(), sourceDir.toAbsolutePath(),
sqliteFile.toAbsolutePath(), sqliteFile.toAbsolutePath(),

View File

@@ -139,6 +139,9 @@ public final class E2ETestContext implements AutoCloseable {
*/ */
private TargetFileCopyPort targetFileCopyPortOverride; private TargetFileCopyPort targetFileCopyPortOverride;
/** Provider identifier written into the attempt history for each batch run. */
private final String providerIdentifier;
private E2ETestContext( private E2ETestContext(
Path sourceFolder, Path sourceFolder,
Path targetFolder, Path targetFolder,
@@ -147,7 +150,8 @@ public final class E2ETestContext implements AutoCloseable {
String jdbcUrl, String jdbcUrl,
SqliteDocumentRecordRepositoryAdapter documentRepo, SqliteDocumentRecordRepositoryAdapter documentRepo,
SqliteProcessingAttemptRepositoryAdapter attemptRepo, SqliteProcessingAttemptRepositoryAdapter attemptRepo,
StubAiInvocationPort aiStub) { StubAiInvocationPort aiStub,
String providerIdentifier) {
this.sourceFolder = sourceFolder; this.sourceFolder = sourceFolder;
this.targetFolder = targetFolder; this.targetFolder = targetFolder;
this.lockFile = lockFile; this.lockFile = lockFile;
@@ -156,19 +160,36 @@ public final class E2ETestContext implements AutoCloseable {
this.documentRepo = documentRepo; this.documentRepo = documentRepo;
this.attemptRepo = attemptRepo; this.attemptRepo = attemptRepo;
this.aiStub = aiStub; this.aiStub = aiStub;
this.providerIdentifier = providerIdentifier;
} }
/** /**
* Initializes a fully wired end-to-end test context rooted in {@code tempDir}. * Initializes a fully wired end-to-end test context rooted in {@code tempDir},
* <p> * using the default provider identifier {@code "openai-compatible"}.
* 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 tempDir the JUnit {@code @TempDir} or any writable temporary directory
* @return a ready-to-use context; caller is responsible for closing it * @return a ready-to-use context; caller is responsible for closing it
* @throws Exception if schema initialization or directory/file creation fails * @throws Exception if schema initialization or directory/file creation fails
*/ */
public static E2ETestContext initialize(Path tempDir) throws Exception { 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.
* <p>
* 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 sourceFolder = Files.createDirectories(tempDir.resolve("source"));
Path targetFolder = Files.createDirectories(tempDir.resolve("target")); Path targetFolder = Files.createDirectories(tempDir.resolve("target"));
Path lockFile = tempDir.resolve("run.lock"); Path lockFile = tempDir.resolve("run.lock");
@@ -189,7 +210,8 @@ public final class E2ETestContext implements AutoCloseable {
return new E2ETestContext( return new E2ETestContext(
sourceFolder, targetFolder, lockFile, promptFile, 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, targetFolderPort,
targetFileCopyPort, targetFileCopyPort,
coordinatorLogger, coordinatorLogger,
MAX_RETRIES_TRANSIENT); MAX_RETRIES_TRANSIENT,
providerIdentifier);
PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile); PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile);
ClockPort clockPort = new SystemClockAdapter(); ClockPort clockPort = new SystemClockAdapter();

View File

@@ -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.
*
* <h2>Test cases covered</h2>
* <ul>
* <li><strong>regressionExistingOpenAiSuiteGreen</strong> — proves the OpenAI-compatible path
* still works end-to-end through the full batch pipeline after the multi-provider
* extension was introduced.</li>
* <li><strong>e2eOpenAiRunWritesProviderIdentifierToHistory</strong> — 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.</li>
* <li><strong>e2eClaudeRunWritesProviderIdentifierToHistory</strong> — verifies that a
* batch run with the {@code claude} provider identifier writes {@code "claude"}
* into the {@code ai_provider} column of the attempt history.</li>
* <li><strong>e2eMigrationFromLegacyDemoConfig</strong> — 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.</li>
* <li><strong>legacyDataFromBeforeV11RemainsReadable</strong> — 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.</li>
* </ul>
*/
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.
* <p>
* 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<ProcessingAttempt> 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.
* <p>
* 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<ProcessingAttempt> 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.
*
* <h2>What is verified</h2>
* <ol>
* <li>The {@code .bak} file exists after migration and its content equals the
* original file content verbatim.</li>
* <li>The migrated file contains {@code ai.provider.active=openai-compatible}.</li>
* <li>The legacy values are mapped to the {@code ai.provider.openai-compatible.*}
* namespace.</li>
* <li>Non-AI keys ({@code source.folder}, {@code max.pages}, …) are preserved
* unchanged.</li>
* <li>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.</li>
* </ol>
*/
@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.
*
* <h2>What is verified</h2>
* <ol>
* <li>A database without the {@code ai_provider} column can be opened and its existing
* rows read without throwing any exception.</li>
* <li>The {@code aiProvider} field for pre-extension rows is {@code null} (no synthesised
* default, no error).</li>
* <li>Other fields on the pre-extension attempt (status, retryable flag) remain
* correctly readable after schema evolution.</li>
* <li>A new batch run on the same database succeeds, proving that the evolved schema
* is fully write-compatible with the legacy data.</li>
* </ol>
*/
@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<ProcessingAttempt> 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();
}
}
}
}