V1.1 Änderungen
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# PDF Umbenenner – Konfigurationsbeispiel für lokale Entwicklung
|
||||
# PDF Umbenenner – Konfigurationsbeispiel fuer lokale Entwicklung
|
||||
# Kopiere diese Datei nach config/application.properties und passe die Werte an.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pflichtparameter
|
||||
# Pflichtparameter (allgemein)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden.
|
||||
@@ -13,22 +13,12 @@ source.folder=./work/local/source
|
||||
# Wird automatisch angelegt, wenn er noch nicht existiert.
|
||||
target.folder=./work/local/target
|
||||
|
||||
# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie.
|
||||
# Das übergeordnete Verzeichnis muss vorhanden sein.
|
||||
# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie.
|
||||
# Das uebergeordnete Verzeichnis muss vorhanden sein.
|
||||
sqlite.file=./work/local/pdf-umbenenner.db
|
||||
|
||||
# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
|
||||
api.baseUrl=https://api.openai.com/v1
|
||||
|
||||
# Modellname des KI-Dienstes.
|
||||
api.model=gpt-4o-mini
|
||||
|
||||
# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein).
|
||||
api.timeoutSeconds=30
|
||||
|
||||
# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
|
||||
# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der
|
||||
# Dokumentstatus auf FAILED_FINAL gesetzt.
|
||||
# Muss eine ganze Zahl >= 1 sein.
|
||||
max.retries.transient=3
|
||||
|
||||
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
|
||||
@@ -42,20 +32,11 @@ max.text.characters=5000
|
||||
# in der Versuchshistorie.
|
||||
prompt.template.file=./config/prompts/template.txt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API-Schlüssel
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der API-Schlüssel kann wahlweise über diese Property oder über die
|
||||
# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden.
|
||||
# Die Umgebungsvariable hat Vorrang.
|
||||
api.key=your-local-api-key-here
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optionale Parameter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen).
|
||||
# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis.
|
||||
# Pfad zur Lock-Datei fuer den Startschutz (verhindert parallele Instanzen).
|
||||
runtime.lock.file=./work/local/pdf-umbenenner.lock
|
||||
|
||||
# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/.
|
||||
@@ -64,7 +45,42 @@ log.directory=./work/local/logs
|
||||
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO.
|
||||
log.level=INFO
|
||||
|
||||
# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben.
|
||||
# Erlaubte Werte: true oder false. Standard ist false (geschützt).
|
||||
# Nur für Diagnosezwecke auf true setzen.
|
||||
# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben.
|
||||
# Erlaubte Werte: true oder false. Standard ist false (geschuetzt).
|
||||
log.ai.sensitive=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aktiver KI-Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
# Erlaubte Werte: openai-compatible, claude
|
||||
ai.provider.active=openai-compatible
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-kompatibler Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
|
||||
ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1
|
||||
|
||||
# Modellname des KI-Dienstes.
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
|
||||
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
|
||||
# API-Schluessel. Die Umgebungsvariable OPENAI_COMPATIBLE_API_KEY hat Vorrang.
|
||||
ai.provider.openai-compatible.apiKey=your-openai-api-key-here
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basis-URL (optional; Standard: https://api.anthropic.com)
|
||||
# ai.provider.claude.baseUrl=https://api.anthropic.com
|
||||
|
||||
# Modellname (z. B. claude-3-5-sonnet-20241022)
|
||||
# ai.provider.claude.model=claude-3-5-sonnet-20241022
|
||||
|
||||
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||
# ai.provider.claude.timeoutSeconds=60
|
||||
|
||||
# API-Schluessel. Die Umgebungsvariable ANTHROPIC_API_KEY hat Vorrang.
|
||||
# ai.provider.claude.apiKey=
|
||||
|
||||
@@ -1,71 +1,46 @@
|
||||
# PDF Umbenenner – Konfigurationsbeispiel für Testläufe
|
||||
# PDF Umbenenner – Konfigurationsbeispiel fuer Testlaeufe
|
||||
# Kopiere diese Datei nach config/application.properties und passe die Werte an.
|
||||
# Diese Vorlage enthält kürzere Timeouts und niedrigere Limits für Testläufe.
|
||||
# Diese Vorlage enthaelt kuerzere Timeouts und niedrigere Limits fuer Testlaeufe.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pflichtparameter
|
||||
# Pflichtparameter (allgemein)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden.
|
||||
# Der Ordner muss vorhanden und lesbar sein.
|
||||
source.folder=./work/test/source
|
||||
|
||||
# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden.
|
||||
# Wird automatisch angelegt, wenn er noch nicht existiert.
|
||||
target.folder=./work/test/target
|
||||
|
||||
# SQLite-Datenbankdatei für Bearbeitungsstatus und Versuchshistorie.
|
||||
# Das übergeordnete Verzeichnis muss vorhanden sein.
|
||||
sqlite.file=./work/test/pdf-umbenenner-test.db
|
||||
|
||||
# Basis-URL des OpenAI-kompatiblen KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
|
||||
api.baseUrl=https://api.openai.com/v1
|
||||
|
||||
# Modellname des KI-Dienstes.
|
||||
api.model=gpt-4o-mini
|
||||
|
||||
# HTTP-Timeout für KI-Anfragen in Sekunden (muss > 0 sein).
|
||||
api.timeoutSeconds=10
|
||||
|
||||
# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
|
||||
# Muss eine ganze Zahl >= 1 sein. Bei Erreichen des Grenzwerts wird der
|
||||
# Dokumentstatus auf FAILED_FINAL gesetzt.
|
||||
max.retries.transient=1
|
||||
|
||||
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
|
||||
# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf).
|
||||
max.pages=5
|
||||
|
||||
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
||||
max.text.characters=2000
|
||||
|
||||
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
||||
# in der Versuchshistorie.
|
||||
prompt.template.file=./config/prompts/template.txt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API-Schlüssel
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der API-Schlüssel kann wahlweise über diese Property oder über die
|
||||
# Umgebungsvariable PDF_UMBENENNER_API_KEY gesetzt werden.
|
||||
# Die Umgebungsvariable hat Vorrang.
|
||||
api.key=test-api-key-placeholder
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optionale Parameter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pfad zur Lock-Datei für den Startschutz (verhindert parallele Instanzen).
|
||||
# Wird weggelassen, verwendet die Anwendung pdf-umbenenner.lock im Arbeitsverzeichnis.
|
||||
runtime.lock.file=./work/test/pdf-umbenenner.lock
|
||||
|
||||
# Log-Verzeichnis. Wird weggelassen, schreibt Log4j2 in ./logs/.
|
||||
log.directory=./work/test/logs
|
||||
|
||||
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard ist INFO.
|
||||
log.level=DEBUG
|
||||
|
||||
# Sensible KI-Inhalte (vollständige Rohantwort und Reasoning) ins Log schreiben.
|
||||
# Erlaubte Werte: true oder false. Standard ist false (geschützt).
|
||||
# Nur für Diagnosezwecke auf true setzen.
|
||||
log.ai.sensitive=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aktiver KI-Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
ai.provider.active=openai-compatible
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-kompatibler Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
ai.provider.openai-compatible.timeoutSeconds=10
|
||||
ai.provider.openai-compatible.apiKey=test-api-key-placeholder
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ai.provider.claude.baseUrl=https://api.anthropic.com
|
||||
# ai.provider.claude.model=claude-3-5-sonnet-20241022
|
||||
# ai.provider.claude.timeoutSeconds=60
|
||||
# ai.provider.claude.apiKey=your-anthropic-api-key-here
|
||||
|
||||
@@ -53,26 +53,45 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
||||
- `config/application-local.example.properties`
|
||||
- `config/application-test.example.properties`
|
||||
|
||||
### Pflichtparameter
|
||||
### Pflichtparameter (allgemein)
|
||||
|
||||
| Parameter | Beschreibung |
|
||||
|------------------------|--------------|
|
||||
|-------------------------|--------------|
|
||||
| `source.folder` | Quellordner mit OCR-PDFs (muss vorhanden und lesbar sein) |
|
||||
| `target.folder` | Zielordner für umbenannte Kopien (wird angelegt, wenn nicht vorhanden) |
|
||||
| `sqlite.file` | SQLite-Datenbankdatei (übergeordnetes Verzeichnis muss existieren) |
|
||||
| `api.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) |
|
||||
| `api.model` | Modellname (z. B. `gpt-4o-mini`) |
|
||||
| `api.timeoutSeconds` | HTTP-Timeout für KI-Anfragen in Sekunden (ganzzahlig, > 0) |
|
||||
| `max.retries.transient`| Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
|
||||
| `ai.provider.active` | Aktiver KI-Provider: `openai-compatible` oder `claude` |
|
||||
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
|
||||
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
|
||||
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
|
||||
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
|
||||
|
||||
### Provider-Parameter
|
||||
|
||||
Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Provider wird nicht validiert.
|
||||
|
||||
**OpenAI-kompatibler Provider** (`ai.provider.active=openai-compatible`):
|
||||
|
||||
| Parameter | Beschreibung |
|
||||
|-----------|--------------|
|
||||
| `ai.provider.openai-compatible.baseUrl` | Basis-URL des KI-Dienstes (z. B. `https://api.openai.com/v1`) |
|
||||
| `ai.provider.openai-compatible.model` | Modellname (z. B. `gpt-4o-mini`) |
|
||||
| `ai.provider.openai-compatible.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) |
|
||||
| `ai.provider.openai-compatible.apiKey` | API-Schlüssel (Umgebungsvariable `OPENAI_COMPATIBLE_API_KEY` hat Vorrang) |
|
||||
|
||||
**Anthropic Claude-Provider** (`ai.provider.active=claude`):
|
||||
|
||||
| Parameter | Beschreibung |
|
||||
|-----------|--------------|
|
||||
| `ai.provider.claude.baseUrl` | Basis-URL (optional; Standard: `https://api.anthropic.com`) |
|
||||
| `ai.provider.claude.model` | Modellname (z. B. `claude-3-5-sonnet-20241022`) |
|
||||
| `ai.provider.claude.timeoutSeconds` | HTTP-Timeout in Sekunden (ganzzahlig, > 0) |
|
||||
| `ai.provider.claude.apiKey` | API-Schlüssel (Umgebungsvariable `ANTHROPIC_API_KEY` hat Vorrang) |
|
||||
|
||||
### Optionale Parameter
|
||||
|
||||
| Parameter | Beschreibung | Standard |
|
||||
|----------------------|--------------|---------|
|
||||
| `api.key` | API-Schlüssel (alternativ: Umgebungsvariable `PDF_UMBENENNER_API_KEY`) | – |
|
||||
|---------------------|--------------|---------|
|
||||
| `runtime.lock.file` | Lock-Datei für Startschutz | `pdf-umbenenner.lock` im Arbeitsverzeichnis |
|
||||
| `log.directory` | Log-Verzeichnis | `./logs/` |
|
||||
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
|
||||
@@ -80,12 +99,52 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
||||
|
||||
### API-Schlüssel
|
||||
|
||||
Der API-Schlüssel kann auf zwei Wegen gesetzt werden:
|
||||
Pro Provider-Familie existiert eine eigene Umgebungsvariable, die Vorrang vor dem Properties-Wert hat:
|
||||
|
||||
1. **Umgebungsvariable `PDF_UMBENENNER_API_KEY`** (empfohlen, hat Vorrang)
|
||||
2. Property `api.key` in `config/application.properties`
|
||||
| Provider | Umgebungsvariable |
|
||||
|---|---|
|
||||
| `openai-compatible` | `OPENAI_COMPATIBLE_API_KEY` |
|
||||
| `claude` | `ANTHROPIC_API_KEY` |
|
||||
|
||||
Die Umgebungsvariable hat immer Vorrang über die Properties-Datei.
|
||||
Schlüssel verschiedener Provider-Familien werden niemals vermischt.
|
||||
|
||||
---
|
||||
|
||||
## Migration älterer Konfigurationsdateien
|
||||
|
||||
Ältere Konfigurationsdateien, die noch die flachen Schlüssel `api.baseUrl`, `api.model`,
|
||||
`api.timeoutSeconds` und `api.key` verwenden, werden beim ersten Start **automatisch**
|
||||
in das aktuelle Schema überführt.
|
||||
|
||||
### Was passiert
|
||||
|
||||
1. Die Anwendung erkennt die veraltete Form anhand der flachen `api.*`-Schlüssel.
|
||||
2. **Vor jeder Änderung** wird eine Sicherungskopie der Originaldatei angelegt:
|
||||
- Standardfall: `config/application.properties.bak`
|
||||
- Falls `.bak` bereits existiert: `config/application.properties.bak.1`, `.bak.2`, …
|
||||
- Bestehende Sicherungen werden **niemals überschrieben**.
|
||||
3. Die Datei wird in-place in das neue Schema überführt:
|
||||
- `api.baseUrl` → `ai.provider.openai-compatible.baseUrl`
|
||||
- `api.model` → `ai.provider.openai-compatible.model`
|
||||
- `api.timeoutSeconds` → `ai.provider.openai-compatible.timeoutSeconds`
|
||||
- `api.key` → `ai.provider.openai-compatible.apiKey`
|
||||
- `ai.provider.active=openai-compatible` wird ergänzt.
|
||||
- Alle übrigen Schlüssel bleiben unverändert.
|
||||
4. Die migrierte Datei wird über eine temporäre Datei (`*.tmp`) und atomischen
|
||||
Move/Rename geschrieben. Das Original wird niemals teilbeschrieben.
|
||||
5. Die migrierte Datei wird sofort neu eingelesen und validiert.
|
||||
|
||||
### Bei Migrationsfehler
|
||||
|
||||
Schlägt die Validierung der migrierten Datei fehl, bricht die Anwendung mit Exit-Code `1` ab.
|
||||
Die Sicherungskopie (`.bak`) bleibt in diesem Fall erhalten und enthält die unveränderte
|
||||
Originaldatei. Die Konfiguration muss dann manuell korrigiert werden.
|
||||
|
||||
### Betreiber-Hinweis
|
||||
|
||||
Die Umgebungsvariable `PDF_UMBENENNER_API_KEY` des Vorgängerstands wird **nicht** automatisch
|
||||
umbenannt. Falls dieser Wert bislang verwendet wurde, muss er auf `OPENAI_COMPATIBLE_API_KEY`
|
||||
umgestellt werden.
|
||||
|
||||
---
|
||||
|
||||
|
||||
149
docs/workpackages/V1.1 - Abschlussnachweis.md
Normal file
149
docs/workpackages/V1.1 - Abschlussnachweis.md
Normal 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.
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
|
||||
@@ -26,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
* <ul>
|
||||
* <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
|
||||
* 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>Distinguishes between successful HTTP responses (200) and technical failures
|
||||
* (timeout, unreachable, connection error, etc.)</li>
|
||||
@@ -36,16 +36,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
* <p>
|
||||
* <strong>Configuration:</strong>
|
||||
* <ul>
|
||||
* <li>{@code apiBaseUrl} — 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 apiTimeoutSeconds} — connection and read timeout in seconds</li>
|
||||
* <li>{@code apiKey} — the authentication token (already resolved from environment
|
||||
* variable {@code PDF_UMBENENNER_API_KEY} or property {@code api.key},
|
||||
* <li>{@code baseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
|
||||
* <li>{@code model} — the model identifier requested from the AI service</li>
|
||||
* <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
|
||||
* <li>{@code apiKey} — the authentication token (resolved from environment variable
|
||||
* {@code OPENAI_COMPATIBLE_API_KEY} or property {@code ai.provider.openai-compatible.apiKey},
|
||||
* environment variable takes precedence)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <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:
|
||||
* <ul>
|
||||
* <li>Authorization header containing the API key</li>
|
||||
@@ -106,19 +106,18 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
private volatile String lastBuiltJsonBody;
|
||||
|
||||
/**
|
||||
* Creates an adapter with configuration from startup configuration.
|
||||
* Creates an adapter from the OpenAI-compatible provider configuration.
|
||||
* <p>
|
||||
* The adapter initializes an HTTP client with the configured timeout and creates
|
||||
* the endpoint URL from the base URL. Configuration values are validated for
|
||||
* null/empty during initialization.
|
||||
* The adapter initializes an HTTP client with the configured timeout and parses
|
||||
* the endpoint URI from the configured base URL string.
|
||||
*
|
||||
* @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 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()
|
||||
.connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds()))
|
||||
.connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -130,25 +129,25 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
* <p>
|
||||
* <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
|
||||
* @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(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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
this.apiBaseUrl = config.apiBaseUrl();
|
||||
this.apiModel = config.apiModel();
|
||||
this.apiBaseUrl = URI.create(config.baseUrl());
|
||||
this.apiModel = config.model();
|
||||
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
|
||||
this.apiTimeoutSeconds = config.apiTimeoutSeconds();
|
||||
this.apiTimeoutSeconds = config.timeoutSeconds();
|
||||
this.httpClient = httpClient;
|
||||
|
||||
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>Headers: Authorization with Bearer token, Content-Type application/json</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>
|
||||
*
|
||||
* @param request the request representation with prompt and document text
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/**
|
||||
* Validates {@link StartConfiguration} before processing can begin.
|
||||
* <p>
|
||||
@@ -156,13 +157,13 @@ public class StartConfigurationValidator {
|
||||
validateSourceFolder(config.sourceFolder(), errors);
|
||||
validateTargetFolder(config.targetFolder(), errors);
|
||||
validateSqliteFile(config.sqliteFile(), errors);
|
||||
validateApiBaseUrl(config.apiBaseUrl(), errors);
|
||||
validateApiModel(config.apiModel(), 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) {
|
||||
validateApiTimeoutSeconds(config.apiTimeoutSeconds(), errors);
|
||||
validateMaxRetriesTransient(config.maxRetriesTransient(), errors);
|
||||
validateMaxPages(config.maxPages(), errors);
|
||||
validateMaxTextCharacters(config.maxTextCharacters(), errors);
|
||||
@@ -199,33 +200,6 @@ public class StartConfigurationValidator {
|
||||
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) {
|
||||
if (maxRetriesTransient < 1) {
|
||||
errors.add("- max.retries.transient: must be >= 1, got: " + maxRetriesTransient);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
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.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.port.out.ConfigurationPort;
|
||||
|
||||
/**
|
||||
* Properties-based implementation of {@link ConfigurationPort}.
|
||||
* <p>
|
||||
* Loads configuration from config/application.properties as the primary source.
|
||||
* For sensitive values, environment variables take precedence: if the environment variable
|
||||
* {@code PDF_UMBENENNER_API_KEY} is set, it overrides the {@code api.key} property from the file.
|
||||
* This allows credentials to be managed securely without storing them in the configuration file.
|
||||
* Loads configuration from {@code config/application.properties} as the primary source.
|
||||
* The multi-provider AI configuration is parsed via {@link MultiProviderConfigurationParser}
|
||||
* and validated via {@link MultiProviderConfigurationValidator}. Environment variables
|
||||
* 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 {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class);
|
||||
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 Path configFilePath;
|
||||
@@ -81,8 +81,9 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
|
||||
@Override
|
||||
public StartConfiguration loadConfiguration() {
|
||||
Properties props = loadPropertiesFile();
|
||||
String apiKey = getApiKey(props);
|
||||
return buildStartConfiguration(props, apiKey);
|
||||
MultiProviderConfiguration multiProviderConfig = parseAndValidateProviders(props);
|
||||
boolean logAiSensitive = parseAiContentSensitivity(props);
|
||||
return buildStartConfiguration(props, multiProviderConfig, logAiSensitive);
|
||||
}
|
||||
|
||||
private Properties loadPropertiesFile() {
|
||||
@@ -100,22 +101,28 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
|
||||
return props;
|
||||
}
|
||||
|
||||
private String escapeBackslashes(String content) {
|
||||
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
|
||||
// This is needed because Windows paths use backslashes (e.g., C:\temp\...)
|
||||
// and Java Properties interprets \t as tab, \n as newline, etc.
|
||||
return content.replace("\\", "\\\\");
|
||||
/**
|
||||
* Parses and validates the multi-provider AI configuration from the given properties.
|
||||
* <p>
|
||||
* Uses {@link MultiProviderConfigurationParser} for parsing and
|
||||
* {@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) {
|
||||
boolean logAiSensitive = parseAiContentSensitivity(props);
|
||||
private StartConfiguration buildStartConfiguration(Properties props,
|
||||
MultiProviderConfiguration multiProviderConfig,
|
||||
boolean logAiSensitive) {
|
||||
return new StartConfiguration(
|
||||
Paths.get(getRequiredProperty(props, "source.folder")),
|
||||
Paths.get(getRequiredProperty(props, "target.folder")),
|
||||
Paths.get(getRequiredProperty(props, "sqlite.file")),
|
||||
parseUri(getRequiredProperty(props, "api.baseUrl")),
|
||||
getRequiredProperty(props, "api.model"),
|
||||
parseInt(getRequiredProperty(props, "api.timeoutSeconds")),
|
||||
multiProviderConfig,
|
||||
parseInt(getRequiredProperty(props, "max.retries.transient")),
|
||||
parseInt(getRequiredProperty(props, "max.pages")),
|
||||
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, "log.directory", "")),
|
||||
getOptionalProperty(props, "log.level", "INFO"),
|
||||
apiKey,
|
||||
logAiSensitive
|
||||
);
|
||||
}
|
||||
|
||||
private String getApiKey(Properties props) {
|
||||
String envApiKey = environmentLookup.apply(API_KEY_ENV_VAR);
|
||||
if (envApiKey != null && !envApiKey.isBlank()) {
|
||||
LOG.info("Using API key from environment variable {}", API_KEY_ENV_VAR);
|
||||
return envApiKey;
|
||||
}
|
||||
String propsApiKey = props.getProperty("api.key");
|
||||
return propsApiKey != null ? propsApiKey : "";
|
||||
private String escapeBackslashes(String content) {
|
||||
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
|
||||
// This is needed because Windows paths use backslashes (e.g., C:\temp\...)
|
||||
// and Java Properties interprets \t as tab, \n as newline, etc.
|
||||
return content.replace("\\", "\\\\");
|
||||
}
|
||||
|
||||
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.
|
||||
* <p>
|
||||
|
||||
@@ -31,9 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
* including all AI traceability fields added during schema evolution.
|
||||
* <p>
|
||||
* <strong>Schema compatibility:</strong> This adapter writes all columns including
|
||||
* the AI traceability columns. When reading rows that were written before schema
|
||||
* evolution, those columns contain {@code NULL} and are mapped to {@code null}
|
||||
* in the Java record.
|
||||
* the AI traceability columns and the provider-identifier column ({@code ai_provider}).
|
||||
* When reading rows that were written before schema evolution, those columns contain
|
||||
* {@code NULL} and are mapped to {@code null} in the Java record.
|
||||
* <p>
|
||||
* <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
|
||||
@@ -129,6 +129,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
failure_class,
|
||||
failure_message,
|
||||
retryable,
|
||||
ai_provider,
|
||||
model_name,
|
||||
prompt_identifier,
|
||||
processed_page_count,
|
||||
@@ -139,7 +140,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
date_source,
|
||||
validated_title,
|
||||
final_target_file_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (Connection connection = getConnection();
|
||||
@@ -157,19 +158,20 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
setNullableString(statement, 7, attempt.failureClass());
|
||||
setNullableString(statement, 8, attempt.failureMessage());
|
||||
statement.setBoolean(9, attempt.retryable());
|
||||
// AI traceability fields
|
||||
setNullableString(statement, 10, attempt.modelName());
|
||||
setNullableString(statement, 11, attempt.promptIdentifier());
|
||||
setNullableInteger(statement, 12, attempt.processedPageCount());
|
||||
setNullableInteger(statement, 13, attempt.sentCharacterCount());
|
||||
setNullableString(statement, 14, attempt.aiRawResponse());
|
||||
setNullableString(statement, 15, attempt.aiReasoning());
|
||||
setNullableString(statement, 16,
|
||||
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
|
||||
// AI provider identifier and AI traceability fields
|
||||
setNullableString(statement, 10, attempt.aiProvider());
|
||||
setNullableString(statement, 11, attempt.modelName());
|
||||
setNullableString(statement, 12, attempt.promptIdentifier());
|
||||
setNullableInteger(statement, 13, attempt.processedPageCount());
|
||||
setNullableInteger(statement, 14, attempt.sentCharacterCount());
|
||||
setNullableString(statement, 15, attempt.aiRawResponse());
|
||||
setNullableString(statement, 16, attempt.aiReasoning());
|
||||
setNullableString(statement, 17,
|
||||
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
|
||||
setNullableString(statement, 18,
|
||||
attempt.dateSource() != null ? attempt.dateSource().name() : null);
|
||||
setNullableString(statement, 18, attempt.validatedTitle());
|
||||
setNullableString(statement, 19, attempt.finalTargetFileName());
|
||||
setNullableString(statement, 19, attempt.validatedTitle());
|
||||
setNullableString(statement, 20, attempt.finalTargetFileName());
|
||||
|
||||
int rowsAffected = statement.executeUpdate();
|
||||
if (rowsAffected != 1) {
|
||||
@@ -204,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
SELECT
|
||||
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||
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,
|
||||
final_target_file_name
|
||||
FROM processing_attempt
|
||||
@@ -255,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
SELECT
|
||||
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||
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,
|
||||
final_target_file_name
|
||||
FROM processing_attempt
|
||||
@@ -312,6 +314,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
||||
rs.getString("failure_class"),
|
||||
rs.getString("failure_message"),
|
||||
rs.getBoolean("retryable"),
|
||||
rs.getString("ai_provider"),
|
||||
rs.getString("model_name"),
|
||||
rs.getString("prompt_identifier"),
|
||||
processedPageCount,
|
||||
|
||||
@@ -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
|
||||
* {@code document_record}</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>
|
||||
*
|
||||
* <h2>Legacy-state migration</h2>
|
||||
@@ -150,6 +153,9 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
||||
/**
|
||||
* Columns to add idempotently to {@code processing_attempt}.
|
||||
* Each entry is {@code [column_name, column_type]}.
|
||||
* <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 = {
|
||||
{"model_name", "TEXT"},
|
||||
@@ -162,6 +168,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
||||
{"date_source", "TEXT"},
|
||||
{"validated_title", "TEXT"},
|
||||
{"final_target_file_name", "TEXT"},
|
||||
{"ai_provider", "TEXT"},
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -229,7 +236,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
||||
* <li>Create {@code document_record} table (if not exists).</li>
|
||||
* <li>Create {@code processing_attempt} table (if not exists).</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>
|
||||
* </ol>
|
||||
* <p>
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,11 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -25,11 +23,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
|
||||
@@ -39,6 +36,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
* <strong>Test strategy:</strong>
|
||||
* Tests inject a mock {@link HttpClient} via the package-private constructor
|
||||
* to exercise the real HTTP adapter path without requiring network access.
|
||||
* Configuration is supplied via {@link ProviderConfiguration}.
|
||||
* <p>
|
||||
* <strong>Coverage goals:</strong>
|
||||
* <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>Full document text is sent (not truncated)</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>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -70,28 +70,12 @@ class OpenAiHttpAdapterTest {
|
||||
@Mock
|
||||
private HttpClient httpClient;
|
||||
|
||||
private StartConfiguration testConfiguration;
|
||||
private ProviderConfiguration testConfiguration;
|
||||
private OpenAiHttpAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testConfiguration = new StartConfiguration(
|
||||
Paths.get("/source"),
|
||||
Paths.get("/target"),
|
||||
Paths.get("/db.sqlite"),
|
||||
URI.create(API_BASE_URL),
|
||||
API_MODEL,
|
||||
TIMEOUT_SECONDS,
|
||||
5,
|
||||
100,
|
||||
5000,
|
||||
Paths.get("/prompt.txt"),
|
||||
Paths.get("/lock"),
|
||||
Paths.get("/logs"),
|
||||
"INFO",
|
||||
API_KEY,
|
||||
false
|
||||
);
|
||||
testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
|
||||
// Use the package-private constructor with injected mock HttpClient
|
||||
adapter = new OpenAiHttpAdapter(testConfiguration, httpClient);
|
||||
}
|
||||
@@ -242,7 +226,6 @@ class OpenAiHttpAdapterTest {
|
||||
verify(httpClient).send(requestCaptor.capture(), any());
|
||||
|
||||
HttpRequest capturedRequest = requestCaptor.getValue();
|
||||
// Verify the timeout was actually configured on the request
|
||||
assertThat(capturedRequest.timeout())
|
||||
.as("HttpRequest timeout should be present")
|
||||
.isPresent()
|
||||
@@ -437,23 +420,8 @@ class OpenAiHttpAdapterTest {
|
||||
@Test
|
||||
@DisplayName("should throw IllegalArgumentException when API base URL is null")
|
||||
void testNullApiBaseUrlThrowsException() {
|
||||
StartConfiguration invalidConfig = new StartConfiguration(
|
||||
Paths.get("/source"),
|
||||
Paths.get("/target"),
|
||||
Paths.get("/db.sqlite"),
|
||||
null, // Invalid: null base URL
|
||||
API_MODEL,
|
||||
TIMEOUT_SECONDS,
|
||||
5,
|
||||
100,
|
||||
5000,
|
||||
Paths.get("/prompt.txt"),
|
||||
Paths.get("/lock"),
|
||||
Paths.get("/logs"),
|
||||
"INFO",
|
||||
API_KEY,
|
||||
false
|
||||
);
|
||||
ProviderConfiguration invalidConfig = new ProviderConfiguration(
|
||||
API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
|
||||
|
||||
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -463,23 +431,8 @@ class OpenAiHttpAdapterTest {
|
||||
@Test
|
||||
@DisplayName("should throw IllegalArgumentException when API model is null")
|
||||
void testNullApiModelThrowsException() {
|
||||
StartConfiguration invalidConfig = new StartConfiguration(
|
||||
Paths.get("/source"),
|
||||
Paths.get("/target"),
|
||||
Paths.get("/db.sqlite"),
|
||||
URI.create(API_BASE_URL),
|
||||
null, // Invalid: null model
|
||||
TIMEOUT_SECONDS,
|
||||
5,
|
||||
100,
|
||||
5000,
|
||||
Paths.get("/prompt.txt"),
|
||||
Paths.get("/lock"),
|
||||
Paths.get("/logs"),
|
||||
"INFO",
|
||||
API_KEY,
|
||||
false
|
||||
);
|
||||
ProviderConfiguration invalidConfig = new ProviderConfiguration(
|
||||
null, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
|
||||
|
||||
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -489,23 +442,8 @@ class OpenAiHttpAdapterTest {
|
||||
@Test
|
||||
@DisplayName("should throw IllegalArgumentException when API model is blank")
|
||||
void testBlankApiModelThrowsException() {
|
||||
StartConfiguration invalidConfig = new StartConfiguration(
|
||||
Paths.get("/source"),
|
||||
Paths.get("/target"),
|
||||
Paths.get("/db.sqlite"),
|
||||
URI.create(API_BASE_URL),
|
||||
" ", // Invalid: blank model
|
||||
TIMEOUT_SECONDS,
|
||||
5,
|
||||
100,
|
||||
5000,
|
||||
Paths.get("/prompt.txt"),
|
||||
Paths.get("/lock"),
|
||||
Paths.get("/logs"),
|
||||
"INFO",
|
||||
API_KEY,
|
||||
false
|
||||
);
|
||||
ProviderConfiguration invalidConfig = new ProviderConfiguration(
|
||||
" ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
|
||||
|
||||
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -516,25 +454,9 @@ class OpenAiHttpAdapterTest {
|
||||
@DisplayName("should handle empty API key gracefully")
|
||||
void testEmptyApiKeyHandled() throws Exception {
|
||||
// Arrange
|
||||
StartConfiguration configWithEmptyKey = new StartConfiguration(
|
||||
Paths.get("/source"),
|
||||
Paths.get("/target"),
|
||||
Paths.get("/db.sqlite"),
|
||||
URI.create(API_BASE_URL),
|
||||
API_MODEL,
|
||||
TIMEOUT_SECONDS,
|
||||
5,
|
||||
100,
|
||||
5000,
|
||||
Paths.get("/prompt.txt"),
|
||||
Paths.get("/lock"),
|
||||
Paths.get("/logs"),
|
||||
"INFO",
|
||||
"", // Empty key
|
||||
false
|
||||
);
|
||||
|
||||
OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(configWithEmptyKey, httpClient);
|
||||
OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(
|
||||
new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""),
|
||||
httpClient);
|
||||
|
||||
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
|
||||
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
|
||||
@@ -548,18 +470,94 @@ class OpenAiHttpAdapterTest {
|
||||
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mandatory AP-003 test cases
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Verifies that the adapter reads all values from the new {@link ProviderConfiguration}
|
||||
* namespace and uses them correctly in outgoing HTTP requests.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("openAiAdapterReadsValuesFromNewNamespace: all ProviderConfiguration fields are used")
|
||||
void openAiAdapterReadsValuesFromNewNamespace() throws Exception {
|
||||
// Arrange: ProviderConfiguration with values distinct from setUp defaults
|
||||
ProviderConfiguration nsConfig = new ProviderConfiguration(
|
||||
"ns-model-v2", 20, "https://provider-ns.example.com", "ns-api-key-abc");
|
||||
OpenAiHttpAdapter nsAdapter = new OpenAiHttpAdapter(nsConfig, httpClient);
|
||||
|
||||
HttpResponse<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
|
||||
|
||||
/**
|
||||
* 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")
|
||||
private HttpResponse<String> mockHttpResponse(int statusCode, String body) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@@ -23,6 +25,13 @@ class StartConfigurationValidatorTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
/** Helper: builds a minimal valid multi-provider configuration for use in tests. */
|
||||
private static MultiProviderConfiguration validMultiProviderConfig() {
|
||||
ProviderConfiguration openAiConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-key");
|
||||
return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_successWithValidConfiguration() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
@@ -34,9 +43,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -44,7 +51,6 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -57,9 +63,7 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
tempDir.resolve("target"),
|
||||
tempDir.resolve("db.sqlite"),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -67,7 +71,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -84,9 +87,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("source"),
|
||||
null,
|
||||
tempDir.resolve("db.sqlite"),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -94,7 +95,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -111,9 +111,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("source"),
|
||||
tempDir.resolve("target"),
|
||||
null,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -121,7 +119,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -133,7 +130,7 @@ class StartConfigurationValidatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenApiBaseUrlIsNull() throws Exception {
|
||||
void validate_failsWhenMultiProviderConfigurationIsNull() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
@@ -144,8 +141,6 @@ class StartConfigurationValidatorTest {
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
null,
|
||||
"gpt-4",
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -153,7 +148,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -161,39 +155,7 @@ class StartConfigurationValidatorTest {
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.baseUrl: must not be null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenApiModelIsNull() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
null,
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
|
||||
assertTrue(exception.getMessage().contains("ai provider configuration: must not be null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -206,9 +168,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -216,7 +176,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -227,38 +186,6 @@ class StartConfigurationValidatorTest {
|
||||
assertTrue(exception.getMessage().contains("prompt.template.file: must not be null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenApiTimeoutSecondsIsZeroOrNegative() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
0,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.timeoutSeconds: must be > 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
@@ -270,9 +197,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
-1,
|
||||
100,
|
||||
50000,
|
||||
@@ -280,7 +205,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -302,9 +226,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
0,
|
||||
100,
|
||||
50000,
|
||||
@@ -312,7 +234,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -335,9 +256,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
0,
|
||||
50000,
|
||||
@@ -345,7 +264,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -367,9 +285,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
-1,
|
||||
@@ -377,7 +293,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -399,9 +314,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
1, // maxRetriesTransient = 1 is the minimum valid value
|
||||
100,
|
||||
50000,
|
||||
@@ -409,7 +322,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -427,9 +339,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
0, // maxTextCharacters = 0 ist ungültig
|
||||
@@ -437,7 +347,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -458,9 +367,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("nonexistent"),
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -468,7 +375,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -490,9 +396,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFile,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -500,7 +404,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -513,8 +416,6 @@ class StartConfigurationValidatorTest {
|
||||
|
||||
@Test
|
||||
void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception {
|
||||
// target.folder is "anlegbar" (creatable): parent tempDir exists, folder itself does not.
|
||||
// The validator must create the folder and accept the configuration.
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
@@ -523,9 +424,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
tempDir.resolve("nonexistent-target"),
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -533,7 +432,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -545,7 +443,6 @@ class StartConfigurationValidatorTest {
|
||||
|
||||
@Test
|
||||
void validate_failsWhenTargetFolderCannotBeCreated() {
|
||||
// Inject a TargetFolderChecker that simulates a creation failure.
|
||||
StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator(
|
||||
path -> null, // source folder checker always passes
|
||||
path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)"
|
||||
@@ -555,9 +452,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("source"),
|
||||
tempDir.resolve("uncreatable-target"),
|
||||
tempDir.resolve("db.sqlite"),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -565,7 +460,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -588,9 +482,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFile,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -598,7 +490,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -619,9 +510,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
tempDir.resolve("nonexistent/db.sqlite"),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -629,7 +518,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -640,70 +528,6 @@ class StartConfigurationValidatorTest {
|
||||
assertTrue(exception.getMessage().contains("sqlite.file: parent directory does not exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenApiBaseUrlIsNotAbsolute() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("/api/v1"),
|
||||
"gpt-4",
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenApiBaseUrlHasUnsupportedScheme() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("ftp://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.baseUrl: scheme must be http or https"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
@@ -714,9 +538,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -724,7 +546,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -746,9 +567,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -756,7 +575,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -777,9 +595,7 @@ class StartConfigurationValidatorTest {
|
||||
sameFolder,
|
||||
sameFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -787,7 +603,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -805,8 +620,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
-1,
|
||||
0,
|
||||
-1,
|
||||
@@ -814,7 +627,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -826,22 +638,13 @@ class StartConfigurationValidatorTest {
|
||||
assertTrue(message.contains("source.folder: must not be null"));
|
||||
assertTrue(message.contains("target.folder: must not be null"));
|
||||
assertTrue(message.contains("sqlite.file: must not be null"));
|
||||
assertTrue(message.contains("api.baseUrl: must not be null"));
|
||||
assertTrue(message.contains("api.model: must not be null or blank"));
|
||||
assertTrue(message.contains("ai provider configuration: must not be null"));
|
||||
assertTrue(message.contains("prompt.template.file: must not be null"));
|
||||
assertTrue(message.contains("api.timeoutSeconds: must be > 0"));
|
||||
assertTrue(message.contains("max.retries.transient: must be >= 1"));
|
||||
assertTrue(message.contains("max.pages: must be > 0"));
|
||||
assertTrue(message.contains("max.text.characters: must be > 0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focused tests for source folder validation using mocked filesystem checks.
|
||||
* <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
|
||||
void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception {
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
@@ -852,9 +655,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("nonexistent"),
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -862,11 +663,9 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
// Mock: always return "does not exist" error for any path
|
||||
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
|
||||
"- source.folder: path does not exist: " + path;
|
||||
|
||||
@@ -889,9 +688,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("somepath"),
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -899,11 +696,9 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
// Mock: simulate path exists but is not a directory
|
||||
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
|
||||
"- source.folder: path is not a directory: " + path;
|
||||
|
||||
@@ -926,9 +721,7 @@ class StartConfigurationValidatorTest {
|
||||
tempDir.resolve("somepath"),
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -936,12 +729,9 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
// Mock: simulate path exists, is directory, but is not readable
|
||||
// This is the critical case that is hard to test on actual FS
|
||||
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
|
||||
"- source.folder: directory is not readable: " + path;
|
||||
|
||||
@@ -965,9 +755,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -975,11 +763,9 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
// Mock: all checks pass (return null)
|
||||
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null;
|
||||
|
||||
StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker);
|
||||
@@ -988,24 +774,19 @@ class StartConfigurationValidatorTest {
|
||||
"Validation should succeed when source folder checker returns null");
|
||||
}
|
||||
|
||||
// Neue Tests zur Verbesserung der Abdeckung
|
||||
|
||||
@Test
|
||||
void validate_failsWhenSqliteFileHasNoParent() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
// Ein Pfad ohne Parent (z.B. einfacher Dateiname)
|
||||
Path sqliteFileWithoutParent = Path.of("db.sqlite");
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFileWithoutParent,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -1013,7 +794,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1030,7 +810,6 @@ class StartConfigurationValidatorTest {
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
// Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
|
||||
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
|
||||
Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite");
|
||||
|
||||
@@ -1038,9 +817,7 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFileWithFileAsParent,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -1048,7 +825,6 @@ class StartConfigurationValidatorTest {
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1059,70 +835,6 @@ class StartConfigurationValidatorTest {
|
||||
assertTrue(exception.getMessage().contains("sqlite.file: parent is not a directory"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_apiModelBlankString() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
" ", // Blank string
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_apiModelEmptyString() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"", // Empty string
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_runtimeLockFileParentDoesNotExist() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
@@ -1134,17 +846,14 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
tempDir.resolve("nonexistent/lock.lock"), // Lock file mit nicht existierendem Parent
|
||||
tempDir.resolve("nonexistent/lock.lock"),
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1162,7 +871,6 @@ class StartConfigurationValidatorTest {
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
// Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
|
||||
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
|
||||
Path lockFileWithFileAsParent = parentFile.resolve("lock.lock");
|
||||
|
||||
@@ -1170,17 +878,14 @@ class StartConfigurationValidatorTest {
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
lockFileWithFileAsParent, // Lock file mit Datei als Parent
|
||||
lockFileWithFileAsParent,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1198,24 +903,20 @@ class StartConfigurationValidatorTest {
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
// Erstelle eine Datei, die als Log-Verzeichnis verwendet wird
|
||||
Path logFileInsteadOfDirectory = Files.createFile(tempDir.resolve("logfile.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
logFileInsteadOfDirectory, // Datei statt Verzeichnis
|
||||
logFileInsteadOfDirectory,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1225,66 +926,4 @@ class StartConfigurationValidatorTest {
|
||||
);
|
||||
assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_apiBaseUrlHttpScheme() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("http://api.example.com"), // HTTP statt HTTPS
|
||||
"gpt-4",
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
assertDoesNotThrow(() -> validator.validate(config),
|
||||
"HTTP scheme should be valid");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_apiBaseUrlNullScheme() throws Exception {
|
||||
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
|
||||
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
|
||||
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("//api.example.com"), // Kein Schema
|
||||
"gpt-4",
|
||||
30,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
promptTemplateFile,
|
||||
null,
|
||||
null,
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
|
||||
InvalidStartConfigurationException exception = assertThrows(
|
||||
InvalidStartConfigurationException.class,
|
||||
() -> validator.validate(config)
|
||||
);
|
||||
// Bei einer URI ohne Schema ist sie nicht absolut, daher kommt zuerst diese Fehlermeldung
|
||||
assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Function;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -19,7 +21,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* Unit tests for {@link PropertiesConfigurationPortAdapter}.
|
||||
* <p>
|
||||
* Tests cover valid configuration loading, missing mandatory properties,
|
||||
* invalid property values, and API-key environment variable precedence.
|
||||
* invalid property values, and API-key environment variable precedence
|
||||
* for the multi-provider schema.
|
||||
*/
|
||||
class PropertiesConfigurationPortAdapterTest {
|
||||
|
||||
@@ -42,13 +45,20 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertNotNull(config);
|
||||
// Use endsWith to handle platform-specific path separators
|
||||
assertTrue(config.sourceFolder().toString().endsWith("source"));
|
||||
assertTrue(config.targetFolder().toString().endsWith("target"));
|
||||
assertTrue(config.sqliteFile().toString().endsWith("db.sqlite"));
|
||||
assertEquals("https://api.example.com", config.apiBaseUrl().toString());
|
||||
assertEquals("gpt-4", config.apiModel());
|
||||
assertEquals(30, config.apiTimeoutSeconds());
|
||||
assertNotNull(config.multiProviderConfiguration());
|
||||
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE,
|
||||
config.multiProviderConfiguration().activeProviderFamily());
|
||||
assertEquals("https://api.example.com",
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().baseUrl());
|
||||
assertEquals("gpt-4",
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().model());
|
||||
assertEquals(30,
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds());
|
||||
assertEquals("test-api-key-from-properties",
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().apiKey());
|
||||
assertEquals(3, config.maxRetriesTransient());
|
||||
assertEquals(100, config.maxPages());
|
||||
assertEquals(50000, config.maxTextCharacters());
|
||||
@@ -56,57 +66,60 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock"));
|
||||
assertTrue(config.logDirectory().toString().endsWith("logs"));
|
||||
assertEquals("DEBUG", config.logLevel());
|
||||
assertEquals("test-api-key-from-properties", config.apiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsAbsent() throws Exception {
|
||||
void loadConfiguration_rejectsBlankApiKeyWhenAbsentAndNoEnvVar() throws Exception {
|
||||
Path configFile = createConfigFile("no-api-key.properties");
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("", config.apiKey(), "API key should be empty when not in properties and no env var");
|
||||
assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
|
||||
adapter::loadConfiguration,
|
||||
"Missing API key must be rejected as invalid configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsNull() throws Exception {
|
||||
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsNull() throws Exception {
|
||||
Path configFile = createConfigFile("no-api-key.properties");
|
||||
|
||||
Function<String, String> envLookup = key -> null;
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("", config.apiKey());
|
||||
assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
|
||||
adapter::loadConfiguration,
|
||||
"Null env var with no properties API key must be rejected as invalid configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsEmpty() throws Exception {
|
||||
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsEmpty() throws Exception {
|
||||
Path configFile = createConfigFile("no-api-key.properties");
|
||||
|
||||
Function<String, String> envLookup = key -> "";
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("", config.apiKey(), "Empty env var should fall back to empty string");
|
||||
assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
|
||||
adapter::loadConfiguration,
|
||||
"Empty env var with no properties API key must be rejected as invalid configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsBlank() throws Exception {
|
||||
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsBlank() throws Exception {
|
||||
Path configFile = createConfigFile("no-api-key.properties");
|
||||
|
||||
Function<String, String> envLookup = key -> " ";
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("", config.apiKey(), "Blank env var should fall back to empty string");
|
||||
assertThrows(
|
||||
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
|
||||
adapter::loadConfiguration,
|
||||
"Blank env var with no properties API key must be rejected as invalid configuration");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,7 +127,7 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
Path configFile = createConfigFile("valid-config.properties");
|
||||
|
||||
Function<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 null;
|
||||
@@ -124,7 +137,9 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("env-api-key-override", config.apiKey(), "Environment variable should override properties");
|
||||
assertEquals("env-api-key-override",
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().apiKey(),
|
||||
"Environment variable must override properties API key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -163,21 +178,22 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=60\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=60\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=5\n" +
|
||||
"max.pages=200\n" +
|
||||
"max.text.characters=100000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals(60, config.apiTimeoutSeconds());
|
||||
assertEquals(60, config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds());
|
||||
assertEquals(5, config.maxRetriesTransient());
|
||||
assertEquals(200, config.maxPages());
|
||||
assertEquals(100000, config.maxTextCharacters());
|
||||
@@ -189,21 +205,24 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds= 45 \n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds= 45 \n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=2\n" +
|
||||
"max.pages=150\n" +
|
||||
"max.text.characters=75000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals(45, config.apiTimeoutSeconds(), "Whitespace should be trimmed from integer values");
|
||||
assertEquals(45,
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds(),
|
||||
"Whitespace should be trimmed from integer values");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -212,14 +231,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=not-a-number\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=not-a-number\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=2\n" +
|
||||
"max.pages=150\n" +
|
||||
"max.text.characters=75000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
@@ -233,26 +253,28 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadConfiguration_parsesUriCorrectly() throws Exception {
|
||||
void loadConfiguration_parsesBaseUrlStringCorrectly() throws Exception {
|
||||
Path configFile = createInlineConfig(
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com:8080/v1\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com:8080/v1\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
|
||||
var config = adapter.loadConfiguration();
|
||||
|
||||
assertEquals("https://api.example.com:8080/v1", config.apiBaseUrl().toString());
|
||||
assertEquals("https://api.example.com:8080/v1",
|
||||
config.multiProviderConfiguration().activeProviderConfiguration().baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,14 +283,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
|
||||
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
|
||||
@@ -282,26 +305,28 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
|
||||
@Test
|
||||
void allConfigurationFailuresAreClassifiedAsConfigurationLoadingException() throws Exception {
|
||||
// Verify that file I/O failure uses ConfigurationLoadingException
|
||||
// File I/O failure
|
||||
Path nonExistentFile = tempDir.resolve("nonexistent.properties");
|
||||
PropertiesConfigurationPortAdapter adapter1 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, nonExistentFile);
|
||||
assertThrows(ConfigurationLoadingException.class, () -> adapter1.loadConfiguration(),
|
||||
"File I/O failure should throw ConfigurationLoadingException");
|
||||
|
||||
// Verify that missing required property uses ConfigurationLoadingException
|
||||
// Missing required property
|
||||
Path missingPropFile = createConfigFile("missing-required.properties");
|
||||
PropertiesConfigurationPortAdapter adapter2 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, missingPropFile);
|
||||
assertThrows(ConfigurationLoadingException.class, () -> adapter2.loadConfiguration(),
|
||||
"Missing required property should throw ConfigurationLoadingException");
|
||||
|
||||
// Verify that invalid integer value uses ConfigurationLoadingException
|
||||
// Invalid integer value
|
||||
Path invalidIntFile = createInlineConfig(
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=invalid\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=invalid\n" +
|
||||
"ai.provider.openai-compatible.apiKey=key\n" +
|
||||
"max.retries.transient=2\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
@@ -311,22 +336,20 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
assertThrows(ConfigurationLoadingException.class, () -> adapter3.loadConfiguration(),
|
||||
"Invalid integer value should throw ConfigurationLoadingException");
|
||||
|
||||
// Verify that invalid URI value uses ConfigurationLoadingException
|
||||
Path invalidUriFile = createInlineConfig(
|
||||
// Unknown ai.provider.active value
|
||||
Path unknownProviderFile = createInlineConfig(
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=not a valid uri\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=unknown-provider\n" +
|
||||
"max.retries.transient=2\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
);
|
||||
PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, invalidUriFile);
|
||||
PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, unknownProviderFile);
|
||||
assertThrows(ConfigurationLoadingException.class, () -> adapter4.loadConfiguration(),
|
||||
"Invalid URI value should throw ConfigurationLoadingException");
|
||||
"Unknown provider identifier should throw ConfigurationLoadingException");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -335,14 +358,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n"
|
||||
"prompt.template.file=/tmp/prompt.txt\n"
|
||||
// log.ai.sensitive intentionally omitted
|
||||
);
|
||||
|
||||
@@ -360,14 +384,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=true\n"
|
||||
);
|
||||
|
||||
@@ -385,14 +410,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=false\n"
|
||||
);
|
||||
|
||||
@@ -410,14 +436,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=TRUE\n"
|
||||
);
|
||||
|
||||
@@ -435,14 +462,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=FALSE\n"
|
||||
);
|
||||
|
||||
@@ -460,14 +488,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=maybe\n"
|
||||
);
|
||||
|
||||
@@ -490,14 +519,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=yes\n"
|
||||
);
|
||||
|
||||
@@ -518,14 +548,15 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
"source.folder=/tmp/source\n" +
|
||||
"target.folder=/tmp/target\n" +
|
||||
"sqlite.file=/tmp/db.sqlite\n" +
|
||||
"api.baseUrl=https://api.example.com\n" +
|
||||
"api.model=gpt-4\n" +
|
||||
"api.timeoutSeconds=30\n" +
|
||||
"ai.provider.active=openai-compatible\n" +
|
||||
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
|
||||
"ai.provider.openai-compatible.model=gpt-4\n" +
|
||||
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
|
||||
"ai.provider.openai-compatible.apiKey=test-key\n" +
|
||||
"max.retries.transient=3\n" +
|
||||
"max.pages=100\n" +
|
||||
"max.text.characters=50000\n" +
|
||||
"prompt.template.file=/tmp/prompt.txt\n" +
|
||||
"api.key=test-key\n" +
|
||||
"log.ai.sensitive=1\n"
|
||||
);
|
||||
|
||||
@@ -544,7 +575,6 @@ class PropertiesConfigurationPortAdapterTest {
|
||||
Path sourceResource = Path.of("src/test/resources", resourceName);
|
||||
Path targetConfigFile = tempDir.resolve("application.properties");
|
||||
|
||||
// Copy content from resource file
|
||||
Files.copy(sourceResource, targetConfigFile);
|
||||
return targetConfigFile;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, runId, 1, startedAt, endedAt,
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
"openai-compatible",
|
||||
"gpt-4o", "prompt-v1.txt",
|
||||
5, 1234,
|
||||
"{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
|
||||
@@ -434,6 +435,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, runId, 1, now, now.plusSeconds(5),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
"openai-compatible",
|
||||
"claude-sonnet-4-6", "prompt-v2.txt",
|
||||
3, 800,
|
||||
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
|
||||
@@ -531,6 +533,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"gpt-4o", "prompt-v1.txt", 2, 500,
|
||||
"{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
|
||||
"Found.", date, DateSource.AI_PROVIDED, "Rechnung",
|
||||
@@ -560,6 +563,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"model-a", "prompt-v1.txt", 1, 100,
|
||||
"{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
|
||||
null
|
||||
@@ -577,6 +581,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"model-b", "prompt-v2.txt", 2, 200,
|
||||
"{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
|
||||
null
|
||||
@@ -606,6 +611,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, runId, 1, now, now.plusSeconds(3),
|
||||
ProcessingStatus.SUCCESS,
|
||||
null, null, false,
|
||||
null,
|
||||
"gpt-4", "prompt-v1.txt", 2, 600,
|
||||
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
|
||||
"Invoice.",
|
||||
@@ -637,6 +643,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"gpt-4", "prompt-v1.txt", 1, 200,
|
||||
"{}", "reason",
|
||||
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
|
||||
@@ -667,6 +674,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"model-a", "prompt-v1.txt", 3, 700,
|
||||
"{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
|
||||
);
|
||||
@@ -679,7 +687,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
ProcessingStatus.SUCCESS,
|
||||
null, null, false,
|
||||
null, null, null, null, null, null,
|
||||
null, null, null,
|
||||
null, null, null, null,
|
||||
"2026-02-10 - Bescheid.pdf"
|
||||
);
|
||||
repository.save(successAttempt);
|
||||
@@ -742,6 +750,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"model-x", "prompt-v1.txt", 1, 50,
|
||||
"{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
|
||||
null
|
||||
@@ -787,6 +796,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
fingerprint, runId, 1, now, now.plusSeconds(5),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
null,
|
||||
"gpt-4o", "prompt-v1.txt",
|
||||
3, 750,
|
||||
fullRawResponse,
|
||||
|
||||
@@ -119,7 +119,8 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
"resolved_date",
|
||||
"date_source",
|
||||
"validated_title",
|
||||
"final_target_file_name"
|
||||
"final_target_file_name",
|
||||
"ai_provider"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
source.folder=/tmp/source
|
||||
target.folder=/tmp/target
|
||||
# sqlite.file is missing
|
||||
api.baseUrl=https://api.example.com
|
||||
api.model=gpt-4
|
||||
api.timeoutSeconds=30
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=https://api.example.com
|
||||
ai.provider.openai-compatible.model=gpt-4
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key
|
||||
max.retries.transient=3
|
||||
max.pages=100
|
||||
max.text.characters=50000
|
||||
prompt.template.file=/tmp/prompt.txt
|
||||
api.key=test-api-key
|
||||
@@ -1,9 +1,10 @@
|
||||
source.folder=/tmp/source
|
||||
target.folder=/tmp/target
|
||||
sqlite.file=/tmp/db.sqlite
|
||||
api.baseUrl=https://api.example.com
|
||||
api.model=gpt-4
|
||||
api.timeoutSeconds=30
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=https://api.example.com
|
||||
ai.provider.openai-compatible.model=gpt-4
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
max.retries.transient=3
|
||||
max.pages=100
|
||||
max.text.characters=50000
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
source.folder=/tmp/source
|
||||
target.folder=/tmp/target
|
||||
sqlite.file=/tmp/db.sqlite
|
||||
api.baseUrl=https://api.example.com
|
||||
api.model=gpt-4
|
||||
api.timeoutSeconds=30
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=https://api.example.com
|
||||
ai.provider.openai-compatible.model=gpt-4
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key-from-properties
|
||||
max.retries.transient=3
|
||||
max.pages=100
|
||||
max.text.characters=50000
|
||||
@@ -11,4 +13,3 @@ prompt.template.file=/tmp/prompt.txt
|
||||
runtime.lock.file=/tmp/lock.lock
|
||||
log.directory=/tmp/logs
|
||||
log.level=DEBUG
|
||||
api.key=test-api-key-from-properties
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.config.startup;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
|
||||
/**
|
||||
* Typed immutable configuration model for PDF Umbenenner startup parameters.
|
||||
* <p>
|
||||
* Contains all technical infrastructure and runtime configuration parameters
|
||||
* loaded and validated at bootstrap time. This is a complete configuration model
|
||||
* for the entire application startup, including paths, API settings, persistence,
|
||||
* for the entire application startup, including paths, AI provider selection, persistence,
|
||||
* and operational parameters.
|
||||
*
|
||||
* <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>
|
||||
* <p>
|
||||
* The boolean property {@code log.ai.sensitive} controls whether sensitive AI-generated
|
||||
@@ -25,9 +33,7 @@ public record StartConfiguration(
|
||||
Path sourceFolder,
|
||||
Path targetFolder,
|
||||
Path sqliteFile,
|
||||
URI apiBaseUrl,
|
||||
String apiModel,
|
||||
int apiTimeoutSeconds,
|
||||
MultiProviderConfiguration multiProviderConfiguration,
|
||||
int maxRetriesTransient,
|
||||
int maxPages,
|
||||
int maxTextCharacters,
|
||||
@@ -35,7 +41,6 @@ public record StartConfiguration(
|
||||
Path runtimeLockFile,
|
||||
Path logDirectory,
|
||||
String logLevel,
|
||||
String apiKey,
|
||||
|
||||
/**
|
||||
* Whether sensitive AI content (raw response, reasoning) may be written to log files.
|
||||
|
||||
@@ -42,6 +42,10 @@ import java.util.Objects;
|
||||
* successful or skip attempts.</li>
|
||||
* <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>
|
||||
* <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
|
||||
* no AI call was made (e.g. pre-check failures or skip attempts).</li>
|
||||
* <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 failureMessage failure description, or {@code null} for non-failure statuses
|
||||
* @param retryable whether this failure should be retried in a later run
|
||||
* @param aiProvider opaque AI provider identifier for this attempt, or {@code null}
|
||||
* @param modelName AI model name, or {@code null} if no AI call was made
|
||||
* @param promptIdentifier prompt identifier, or {@code null} if no AI call was made
|
||||
* @param processedPageCount number of PDF pages processed, or {@code null}
|
||||
@@ -97,6 +102,7 @@ public record ProcessingAttempt(
|
||||
String failureMessage,
|
||||
boolean retryable,
|
||||
// AI traceability fields (null for non-AI attempts)
|
||||
String aiProvider,
|
||||
String modelName,
|
||||
String promptIdentifier,
|
||||
Integer processedPageCount,
|
||||
@@ -131,7 +137,8 @@ public record ProcessingAttempt(
|
||||
* Creates a {@link ProcessingAttempt} with no AI traceability fields set.
|
||||
* <p>
|
||||
* Convenience factory for pre-check failures, skip events, and any attempt
|
||||
* that does not involve an AI call.
|
||||
* that does not involve an AI call. The {@link #aiProvider()} field is set
|
||||
* to {@code null}.
|
||||
*
|
||||
* @param fingerprint document identity; must not be null
|
||||
* @param runId batch run identifier; must not be null
|
||||
@@ -157,6 +164,6 @@ public record ProcessingAttempt(
|
||||
return new ProcessingAttempt(
|
||||
fingerprint, runId, attemptNumber, startedAt, endedAt,
|
||||
status, failureClass, failureMessage, retryable,
|
||||
null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,15 +154,22 @@ public class DocumentProcessingCoordinator {
|
||||
private final TargetFileCopyPort targetFileCopyPort;
|
||||
private final ProcessingLogger logger;
|
||||
private final int maxRetriesTransient;
|
||||
private final String activeProviderIdentifier;
|
||||
|
||||
/**
|
||||
* Creates the document processing coordinator with all required ports, logger, and
|
||||
* the transient retry limit.
|
||||
* Creates the document processing coordinator with all required ports, logger,
|
||||
* the transient retry limit, and the active AI provider identifier.
|
||||
* <p>
|
||||
* {@code maxRetriesTransient} is the maximum number of historised transient error attempts
|
||||
* per fingerprint before the document is finalised to
|
||||
* {@link ProcessingStatus#FAILED_FINAL}. The attempt that causes the counter to
|
||||
* reach this value finalises the document. Must be >= 1.
|
||||
* <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;
|
||||
* must not be null
|
||||
@@ -176,8 +183,11 @@ public class DocumentProcessingCoordinator {
|
||||
* @param logger for processing-related logging; must not be null
|
||||
* @param maxRetriesTransient maximum number of historised transient error attempts
|
||||
* before finalisation; must be >= 1
|
||||
* @param activeProviderIdentifier opaque identifier of the active AI provider for this run;
|
||||
* must not be null or blank
|
||||
* @throws NullPointerException if any object parameter is null
|
||||
* @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1
|
||||
* @throws IllegalArgumentException if {@code maxRetriesTransient} is less than 1, or
|
||||
* if {@code activeProviderIdentifier} is blank
|
||||
*/
|
||||
public DocumentProcessingCoordinator(
|
||||
DocumentRecordRepository documentRecordRepository,
|
||||
@@ -186,11 +196,16 @@ public class DocumentProcessingCoordinator {
|
||||
TargetFolderPort targetFolderPort,
|
||||
TargetFileCopyPort targetFileCopyPort,
|
||||
ProcessingLogger logger,
|
||||
int maxRetriesTransient) {
|
||||
int maxRetriesTransient,
|
||||
String activeProviderIdentifier) {
|
||||
if (maxRetriesTransient < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
|
||||
}
|
||||
Objects.requireNonNull(activeProviderIdentifier, "activeProviderIdentifier must not be null");
|
||||
if (activeProviderIdentifier.isBlank()) {
|
||||
throw new IllegalArgumentException("activeProviderIdentifier must not be blank");
|
||||
}
|
||||
this.documentRecordRepository =
|
||||
Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
|
||||
this.processingAttemptRepository =
|
||||
@@ -203,6 +218,7 @@ public class DocumentProcessingCoordinator {
|
||||
Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
this.maxRetriesTransient = maxRetriesTransient;
|
||||
this.activeProviderIdentifier = activeProviderIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,7 +519,7 @@ public class DocumentProcessingCoordinator {
|
||||
ProcessingAttempt successAttempt = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, attemptStart, now,
|
||||
ProcessingStatus.SUCCESS, null, null, false,
|
||||
null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
resolvedFilename);
|
||||
|
||||
DocumentRecord successRecord = buildSuccessRecord(
|
||||
@@ -951,6 +967,7 @@ public class DocumentProcessingCoordinator {
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
activeProviderIdentifier,
|
||||
ctx.modelName(), ctx.promptIdentifier(),
|
||||
ctx.processedPageCount(), ctx.sentCharacterCount(),
|
||||
ctx.aiRawResponse(),
|
||||
@@ -964,6 +981,7 @@ public class DocumentProcessingCoordinator {
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
activeProviderIdentifier,
|
||||
ctx.modelName(), ctx.promptIdentifier(),
|
||||
ctx.processedPageCount(), ctx.sentCharacterCount(),
|
||||
ctx.aiRawResponse(),
|
||||
@@ -976,6 +994,7 @@ public class DocumentProcessingCoordinator {
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
activeProviderIdentifier,
|
||||
ctx.modelName(), ctx.promptIdentifier(),
|
||||
ctx.processedPageCount(), ctx.sentCharacterCount(),
|
||||
ctx.aiRawResponse(),
|
||||
|
||||
@@ -90,7 +90,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
|
||||
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
candidate = new SourceDocumentCandidate(
|
||||
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
|
||||
@@ -250,7 +250,8 @@ class DocumentProcessingCoordinatorTest {
|
||||
// With maxRetriesTransient=1, the very first transient error finalises the document
|
||||
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 1,
|
||||
"openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
|
||||
DocumentProcessingOutcome outcome = new TechnicalDocumentError(
|
||||
@@ -668,7 +669,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
|
||||
DocumentProcessingOutcome outcome = new PreCheckPassed(
|
||||
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
|
||||
@@ -686,7 +687,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
|
||||
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
|
||||
DocumentProcessingOutcome outcome = new PreCheckPassed(
|
||||
@@ -705,7 +706,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
|
||||
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
|
||||
DocumentProcessingOutcome outcome = new PreCheckFailed(
|
||||
@@ -724,7 +725,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
DocumentProcessingOutcome outcome = new PreCheckPassed(
|
||||
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
|
||||
@@ -742,7 +743,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
unitOfWorkPort.failOnExecute = true;
|
||||
DocumentProcessingOutcome outcome = new PreCheckPassed(
|
||||
@@ -761,7 +762,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
|
||||
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
|
||||
DocumentProcessingOutcome outcome = new PreCheckPassed(
|
||||
@@ -780,7 +781,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
|
||||
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
|
||||
unitOfWorkPort.failOnExecute = true;
|
||||
@@ -848,6 +849,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
ProcessingAttempt badProposal = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
null,
|
||||
"model", "prompt", 1, 100, "{}", "reason",
|
||||
null, DateSource.AI_PROVIDED, "Rechnung", null);
|
||||
attemptRepo.savedAttempts.add(badProposal);
|
||||
@@ -871,7 +873,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
boolean result = coordinatorWithFailingFolder.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> null);
|
||||
@@ -893,7 +895,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
boolean result = coordinatorWithFailingCopy.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> null);
|
||||
@@ -915,6 +917,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
ProcessingAttempt badProposal = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
null,
|
||||
"model", "prompt", 1, 100, "{}", "reason",
|
||||
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
|
||||
"A".repeat(21), null);
|
||||
@@ -941,6 +944,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
ProcessingAttempt badProposal = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
null,
|
||||
"model", "prompt", 1, 100, "{}", "reason",
|
||||
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
|
||||
"Rechnung-2026", null);
|
||||
@@ -980,7 +984,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> {
|
||||
@@ -1014,7 +1018,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> null);
|
||||
@@ -1044,7 +1048,8 @@ class DocumentProcessingCoordinatorTest {
|
||||
CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both
|
||||
DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1);
|
||||
new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1,
|
||||
"openai-compatible");
|
||||
|
||||
boolean result = coordinatorWith1Retry.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> null);
|
||||
@@ -1079,7 +1084,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1105,7 +1110,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
|
||||
1 /* maxRetriesTransient=1 → immediately final */);
|
||||
1 /* maxRetriesTransient=1 → immediately final */, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1128,7 +1133,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCountingCopy = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCountingCopy.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart,
|
||||
@@ -1197,7 +1202,8 @@ class DocumentProcessingCoordinatorTest {
|
||||
// maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
|
||||
DocumentProcessingCoordinator coordinatorWith2Retries = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 2,
|
||||
"openai-compatible");
|
||||
DocumentProcessingOutcome transientError = new TechnicalDocumentError(candidate, "Timeout", null);
|
||||
|
||||
// Run 1: new document, first transient error → FAILED_RETRYABLE, transientErrorCount=1
|
||||
@@ -1233,6 +1239,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
return new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
"openai-compatible",
|
||||
"gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
|
||||
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
|
||||
}
|
||||
@@ -1495,7 +1502,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentTerminalSuccess(
|
||||
buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero())));
|
||||
|
||||
@@ -1516,7 +1523,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(
|
||||
buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0))));
|
||||
|
||||
@@ -1537,7 +1544,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
|
||||
coordinatorWithCapturing.process(candidate, fingerprint,
|
||||
@@ -1560,7 +1567,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing =
|
||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
// Existing record already has one content error — second content error finalises
|
||||
recordRepo.setLookupResult(new DocumentKnownProcessable(
|
||||
buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0))));
|
||||
@@ -1596,7 +1603,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1612,6 +1619,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
ProcessingAttempt badProposal = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
null,
|
||||
"model", "prompt", 1, 100, "{}", "reason",
|
||||
null, DateSource.AI_PROVIDED, "Rechnung", null);
|
||||
attemptRepo.savedAttempts.add(badProposal);
|
||||
@@ -1620,7 +1628,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1639,7 +1647,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1658,7 +1666,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart,
|
||||
@@ -1680,7 +1688,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart,
|
||||
@@ -1702,7 +1710,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), bothFail, capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart, c -> null);
|
||||
@@ -1723,7 +1731,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart,
|
||||
@@ -1843,7 +1851,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(
|
||||
candidate, fingerprint, context, attemptStart,
|
||||
@@ -1873,7 +1881,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
@@ -1897,7 +1905,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWorkPort,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT);
|
||||
DEFAULT_MAX_RETRIES_TRANSIENT, "openai-compatible");
|
||||
|
||||
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
|
||||
@@ -356,6 +356,7 @@ class TargetFilenameBuildingServiceTest {
|
||||
Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY,
|
||||
null, null, false,
|
||||
"openai-compatible",
|
||||
"gpt-4", "prompt-v1.txt", 1, 100,
|
||||
"{}", "reasoning text",
|
||||
date, DateSource.AI_PROVIDED, title,
|
||||
|
||||
@@ -469,7 +469,7 @@ class BatchRunProcessingUseCaseTest {
|
||||
DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
|
||||
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
|
||||
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
|
||||
new NoOpProcessingLogger(), 3) {
|
||||
new NoOpProcessingLogger(), 3, "openai-compatible") {
|
||||
@Override
|
||||
public boolean processDeferredOutcome(
|
||||
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
|
||||
@@ -517,7 +517,7 @@ class BatchRunProcessingUseCaseTest {
|
||||
DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
|
||||
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
|
||||
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
|
||||
new NoOpProcessingLogger(), 3) {
|
||||
new NoOpProcessingLogger(), 3, "openai-compatible") {
|
||||
private int callCount = 0;
|
||||
|
||||
@Override
|
||||
@@ -760,7 +760,8 @@ class BatchRunProcessingUseCaseTest {
|
||||
|
||||
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWork,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
|
||||
"openai-compatible");
|
||||
|
||||
// Fingerprint port returns the pre-defined fingerprint for this candidate
|
||||
FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
|
||||
@@ -807,7 +808,8 @@ class BatchRunProcessingUseCaseTest {
|
||||
|
||||
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWork,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
|
||||
"openai-compatible");
|
||||
|
||||
FingerprintPort fixedFingerprintPort = c -> new FingerprintSuccess(fingerprint);
|
||||
|
||||
@@ -860,7 +862,8 @@ class BatchRunProcessingUseCaseTest {
|
||||
|
||||
DocumentProcessingCoordinator realCoordinator = new DocumentProcessingCoordinator(
|
||||
recordRepo, attemptRepo, unitOfWork,
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
|
||||
"openai-compatible");
|
||||
|
||||
FingerprintPort perCandidateFingerprintPort = candidate -> {
|
||||
if (candidate.uniqueIdentifier().equals("terminal.pdf")) return new FingerprintSuccess(terminalFp);
|
||||
@@ -1152,7 +1155,8 @@ class BatchRunProcessingUseCaseTest {
|
||||
private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
|
||||
NoOpDocumentProcessingCoordinator() {
|
||||
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
|
||||
"openai-compatible");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1168,8 @@ class BatchRunProcessingUseCaseTest {
|
||||
|
||||
TrackingDocumentProcessingCoordinator() {
|
||||
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3);
|
||||
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(), 3,
|
||||
"openai-compatible");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
|
||||
@@ -27,6 +27,8 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
||||
@@ -68,6 +70,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
* configuration is handed to the use case factory which extracts the minimal runtime
|
||||
* configuration for the application layer.
|
||||
*
|
||||
* <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>
|
||||
* <ul>
|
||||
* <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>
|
||||
* The production constructor wires the following key adapters:
|
||||
* <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 SqliteSchemaInitializationAdapter} — initializes SQLite schema (including target-copy
|
||||
* schema evolution) at startup.</li>
|
||||
* <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.</li>
|
||||
* <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li>
|
||||
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</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 final MigrationStep migrationStep;
|
||||
private final ConfigurationPortFactory configPortFactory;
|
||||
private final RunLockPortFactory runLockPortFactory;
|
||||
private final ValidatorFactory validatorFactory;
|
||||
@@ -110,6 +121,19 @@ public class BootstrapRunner {
|
||||
private final UseCaseFactory useCaseFactory;
|
||||
private final CommandFactory commandFactory;
|
||||
|
||||
/**
|
||||
* Functional interface encapsulating the legacy configuration migration step.
|
||||
* <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.
|
||||
*/
|
||||
@@ -175,12 +199,12 @@ public class BootstrapRunner {
|
||||
* Wires the processing pipeline with the following adapters:
|
||||
* <ul>
|
||||
* <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 SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li>
|
||||
* <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li>
|
||||
* <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
|
||||
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL and target-copy schema
|
||||
* evolution at startup.</li>
|
||||
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.</li>
|
||||
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
|
||||
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</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.
|
||||
*/
|
||||
public BootstrapRunner() {
|
||||
this.migrationStep = () -> new LegacyConfigurationMigrator()
|
||||
.migrateIfLegacy(Paths.get("config/application.properties"));
|
||||
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
|
||||
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
|
||||
this.validatorFactory = StartConfigurationValidator::new;
|
||||
@@ -206,7 +232,13 @@ public class BootstrapRunner {
|
||||
this.useCaseFactory = (startConfig, lock) -> {
|
||||
// Extract runtime configuration from startup configuration
|
||||
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
|
||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
||||
|
||||
// Select the active AI provider adapter
|
||||
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
|
||||
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
||||
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
||||
|
||||
String jdbcUrl = buildJdbcUrl(startConfig);
|
||||
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
||||
@@ -216,17 +248,18 @@ public class BootstrapRunner {
|
||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||
UnitOfWorkPort unitOfWorkPort =
|
||||
new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||
// Wire coordinators logger with AI content sensitivity setting
|
||||
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class, aiContentSensitivity);
|
||||
// Wire coordinator logger with AI content sensitivity setting
|
||||
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
|
||||
DocumentProcessingCoordinator.class, aiContentSensitivity);
|
||||
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
||||
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
|
||||
DocumentProcessingCoordinator documentProcessingCoordinator =
|
||||
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
|
||||
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
|
||||
startConfig.maxRetriesTransient());
|
||||
startConfig.maxRetriesTransient(),
|
||||
activeFamily.getIdentifier());
|
||||
|
||||
// Wire AI naming pipeline
|
||||
AiInvocationPort aiInvocationPort = new OpenAiHttpAdapter(startConfig);
|
||||
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
|
||||
ClockPort clockPort = new SystemClockAdapter();
|
||||
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
|
||||
@@ -234,11 +267,12 @@ public class BootstrapRunner {
|
||||
aiInvocationPort,
|
||||
promptPort,
|
||||
aiResponseValidator,
|
||||
startConfig.apiModel(),
|
||||
providerConfig.model(),
|
||||
startConfig.maxTextCharacters());
|
||||
|
||||
// Wire use case logger with AI content sensitivity setting
|
||||
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
|
||||
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
|
||||
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
|
||||
return new DefaultBatchRunProcessingUseCase(
|
||||
runtimeConfig,
|
||||
lock,
|
||||
@@ -254,6 +288,9 @@ public class BootstrapRunner {
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with custom factories for testing.
|
||||
* <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 runLockPortFactory factory for creating RunLockPort instances
|
||||
@@ -268,6 +305,32 @@ public class BootstrapRunner {
|
||||
SchemaInitializationPortFactory schemaInitPortFactory,
|
||||
UseCaseFactory useCaseFactory,
|
||||
CommandFactory commandFactory) {
|
||||
this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ },
|
||||
configPortFactory, runLockPortFactory, validatorFactory,
|
||||
schemaInitPortFactory, useCaseFactory, commandFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BootstrapRunner with all factories including an explicit migration step.
|
||||
* <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.runLockPortFactory = runLockPortFactory;
|
||||
this.validatorFactory = validatorFactory;
|
||||
@@ -299,6 +362,7 @@ public class BootstrapRunner {
|
||||
LOG.info("Bootstrap flow started.");
|
||||
try {
|
||||
// Bootstrap Phase: prepare configuration and persistence
|
||||
migrateConfigurationIfNeeded();
|
||||
StartConfiguration config = loadAndValidateConfiguration();
|
||||
initializeSchema(config);
|
||||
// Execution Phase: run batch processing
|
||||
@@ -318,6 +382,20 @@ public class BootstrapRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the legacy configuration migration step exactly once before configuration loading.
|
||||
* <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
|
||||
* {@link StartConfigurationValidator}.
|
||||
@@ -329,13 +407,17 @@ public class BootstrapRunner {
|
||||
* creatable (validator attempts {@code Files.createDirectories} if absent;
|
||||
* failure here is a hard startup error).</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>
|
||||
* <p>
|
||||
* After successful validation, the active AI provider identifier is logged at INFO level.
|
||||
*/
|
||||
private StartConfiguration loadAndValidateConfiguration() {
|
||||
ConfigurationPort configPort = configPortFactory.create();
|
||||
StartConfiguration config = configPort.loadConfiguration();
|
||||
validatorFactory.create().validate(config);
|
||||
LOG.info("Active AI provider: {}",
|
||||
config.multiProviderConfiguration().activeProviderFamily().getIdentifier());
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
||||
@@ -17,10 +20,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -51,9 +52,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Files.createDirectories(tempDir.resolve("source")),
|
||||
Files.createDirectories(tempDir.resolve("target")),
|
||||
Files.createFile(tempDir.resolve("db.sqlite")),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -61,7 +60,6 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
null, // null runtimeLockFile
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -101,14 +99,12 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Files.createDirectories(tempDir.resolve("source")),
|
||||
Files.createDirectories(tempDir.resolve("target")),
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30, 3, 100, 50000,
|
||||
validMultiProviderConfig(),
|
||||
3, 100, 50000,
|
||||
Files.createFile(tempDir.resolve("prompt.txt")),
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -128,14 +124,12 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Files.createDirectories(tempDir.resolve("source")),
|
||||
Files.createDirectories(tempDir.resolve("target")),
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30, 3, 100, 50000,
|
||||
validMultiProviderConfig(),
|
||||
3, 100, 50000,
|
||||
Files.createFile(tempDir.resolve("prompt.txt")),
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -157,13 +151,12 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Files.createDirectories(tempDir.resolve("source")),
|
||||
Files.createDirectories(tempDir.resolve("target")),
|
||||
Files.createFile(tempDir.resolve("db.sqlite")),
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4", 30, 3, 100, 50000,
|
||||
validMultiProviderConfig(),
|
||||
3, 100, 50000,
|
||||
Files.createFile(tempDir.resolve("prompt.txt")),
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -226,9 +219,9 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Path dbFile = Files.createFile(tempDir.resolve("db.sqlite"));
|
||||
Path promptFile = Files.createFile(tempDir.resolve("prompt.txt"));
|
||||
return new StartConfiguration(sourceDir, targetDir, dbFile,
|
||||
URI.create("https://api.example.com"), "gpt-4", 30, 3, 100, 50000,
|
||||
validMultiProviderConfig(), 3, 100, 50000,
|
||||
promptFile, tempDir.resolve("lock.lock"), tempDir.resolve("logs"),
|
||||
"INFO", "key", false);
|
||||
"INFO", false);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -342,9 +335,19 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
"logAiSensitive=true must resolve to LOG_SENSITIVE_CONTENT");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static MultiProviderConfiguration validMultiProviderConfig() {
|
||||
ProviderConfiguration openAiConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-api-key");
|
||||
return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// =========================================================================
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static class MockConfigurationPort implements ConfigurationPort {
|
||||
private final Path tempDir;
|
||||
@@ -373,13 +376,16 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
Files.createFile(promptTemplateFile);
|
||||
}
|
||||
|
||||
ProviderConfiguration openAiConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-api-key");
|
||||
MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
|
||||
AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
|
||||
|
||||
return new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
multiConfig,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -387,7 +393,6 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
||||
@@ -13,13 +18,21 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Configuration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -176,9 +189,7 @@ class BootstrapRunnerTest {
|
||||
sourceDir,
|
||||
targetDir,
|
||||
dbFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -186,7 +197,6 @@ class BootstrapRunnerTest {
|
||||
Paths.get(""), // empty – simulates unconfigured runtime.lock.file
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -262,9 +272,7 @@ class BootstrapRunnerTest {
|
||||
sourceDir,
|
||||
targetDir,
|
||||
dbFile,
|
||||
java.net.URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
validMultiProviderConfig(),
|
||||
0, // max.retries.transient = 0 is invalid (must be >= 1)
|
||||
100,
|
||||
50000,
|
||||
@@ -272,7 +280,6 @@ class BootstrapRunnerTest {
|
||||
tempDir.resolve("lock-mrt.lock"),
|
||||
null,
|
||||
"INFO",
|
||||
"test-key",
|
||||
false
|
||||
);
|
||||
|
||||
@@ -346,6 +353,121 @@ class BootstrapRunnerTest {
|
||||
assertEquals(1, exitCode, "Schema initialization failure should return exit code 1");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mandatory test case: activeProviderIsLoggedAtRunStart
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* The active AI provider identifier must be logged at INFO level during the bootstrap phase,
|
||||
* after configuration is loaded and validated but before batch processing begins.
|
||||
*/
|
||||
@Test
|
||||
void activeProviderIsLoggedAtRunStart() throws Exception {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
List<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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -377,13 +499,16 @@ class BootstrapRunnerTest {
|
||||
Files.createFile(promptTemplateFile);
|
||||
}
|
||||
|
||||
ProviderConfiguration openAiConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-api-key");
|
||||
MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
|
||||
AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
|
||||
|
||||
return new StartConfiguration(
|
||||
sourceFolder,
|
||||
targetFolder,
|
||||
sqliteFile,
|
||||
URI.create("https://api.example.com"),
|
||||
"gpt-4",
|
||||
30,
|
||||
multiConfig,
|
||||
3,
|
||||
100,
|
||||
50000,
|
||||
@@ -391,7 +516,6 @@ class BootstrapRunnerTest {
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
"test-api-key",
|
||||
false
|
||||
);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,16 @@ class ExecutableJarSmokeTestIT {
|
||||
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
|
||||
Files.writeString(promptTemplateFile, "Test prompt template for smoke test.");
|
||||
|
||||
// Write valid application.properties
|
||||
Path configFile = configDir.resolve("application.properties");
|
||||
String validConfig = """
|
||||
source.folder=%s
|
||||
target.folder=%s
|
||||
sqlite.file=%s
|
||||
api.baseUrl=http://localhost:8080/api
|
||||
api.model=gpt-4o-mini
|
||||
api.timeoutSeconds=30
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key-for-smoke-test
|
||||
max.retries.transient=3
|
||||
max.pages=10
|
||||
max.text.characters=5000
|
||||
@@ -68,7 +69,6 @@ class ExecutableJarSmokeTestIT {
|
||||
runtime.lock.file=%s/lock.pid
|
||||
log.directory=%s
|
||||
log.level=INFO
|
||||
api.key=test-api-key-for-smoke-test
|
||||
""".formatted(
|
||||
sourceDir.toAbsolutePath(),
|
||||
targetDir.toAbsolutePath(),
|
||||
@@ -185,16 +185,17 @@ class ExecutableJarSmokeTestIT {
|
||||
source.folder=%s
|
||||
# target.folder is intentionally missing - should cause validation failure
|
||||
sqlite.file=%s
|
||||
api.baseUrl=http://localhost:8080/api
|
||||
api.model=gpt-4o-mini
|
||||
api.timeoutSeconds=30
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key
|
||||
max.retries.transient=3
|
||||
max.pages=10
|
||||
max.text.characters=5000
|
||||
prompt.template.file=%s
|
||||
log.directory=%s/logs
|
||||
log.level=INFO
|
||||
api.key=test-api-key
|
||||
""".formatted(
|
||||
sourceDir.toAbsolutePath(),
|
||||
sqliteFile.toAbsolutePath(),
|
||||
|
||||
@@ -139,6 +139,9 @@ public final class E2ETestContext implements AutoCloseable {
|
||||
*/
|
||||
private TargetFileCopyPort targetFileCopyPortOverride;
|
||||
|
||||
/** Provider identifier written into the attempt history for each batch run. */
|
||||
private final String providerIdentifier;
|
||||
|
||||
private E2ETestContext(
|
||||
Path sourceFolder,
|
||||
Path targetFolder,
|
||||
@@ -147,7 +150,8 @@ public final class E2ETestContext implements AutoCloseable {
|
||||
String jdbcUrl,
|
||||
SqliteDocumentRecordRepositoryAdapter documentRepo,
|
||||
SqliteProcessingAttemptRepositoryAdapter attemptRepo,
|
||||
StubAiInvocationPort aiStub) {
|
||||
StubAiInvocationPort aiStub,
|
||||
String providerIdentifier) {
|
||||
this.sourceFolder = sourceFolder;
|
||||
this.targetFolder = targetFolder;
|
||||
this.lockFile = lockFile;
|
||||
@@ -156,19 +160,36 @@ public final class E2ETestContext implements AutoCloseable {
|
||||
this.documentRepo = documentRepo;
|
||||
this.attemptRepo = attemptRepo;
|
||||
this.aiStub = aiStub;
|
||||
this.providerIdentifier = providerIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a fully wired end-to-end test context rooted in {@code tempDir}.
|
||||
* <p>
|
||||
* Creates the {@code source/}, {@code target/} subdirectories and a minimal prompt
|
||||
* file, initializes the SQLite schema, and wires all adapters.
|
||||
* Initializes a fully wired end-to-end test context rooted in {@code tempDir},
|
||||
* using the default provider identifier {@code "openai-compatible"}.
|
||||
*
|
||||
* @param tempDir the JUnit {@code @TempDir} or any writable temporary directory
|
||||
* @return a ready-to-use context; caller is responsible for closing it
|
||||
* @throws Exception if schema initialization or directory/file creation fails
|
||||
*/
|
||||
public static E2ETestContext initialize(Path tempDir) throws Exception {
|
||||
return initializeWithProvider(tempDir, "openai-compatible");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a fully wired end-to-end test context rooted in {@code tempDir} with
|
||||
* a configurable provider identifier written into each attempt's history record.
|
||||
* <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 targetFolder = Files.createDirectories(tempDir.resolve("target"));
|
||||
Path lockFile = tempDir.resolve("run.lock");
|
||||
@@ -189,7 +210,8 @@ public final class E2ETestContext implements AutoCloseable {
|
||||
|
||||
return new E2ETestContext(
|
||||
sourceFolder, targetFolder, lockFile, promptFile,
|
||||
jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort());
|
||||
jdbcUrl, documentRepo, attemptRepo, new StubAiInvocationPort(),
|
||||
providerIdentifier);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -377,7 +399,8 @@ public final class E2ETestContext implements AutoCloseable {
|
||||
targetFolderPort,
|
||||
targetFileCopyPort,
|
||||
coordinatorLogger,
|
||||
MAX_RETRIES_TRANSIENT);
|
||||
MAX_RETRIES_TRANSIENT,
|
||||
providerIdentifier);
|
||||
|
||||
PromptPort promptPort = new FilesystemPromptPortAdapter(promptFile);
|
||||
ClockPort clockPort = new SystemClockAdapter();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user