diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java
index 88ea982..1d87403 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapterTest.java
@@ -602,6 +602,64 @@ class AnthropicClaudeHttpAdapterTest {
.startsWith("https://api.anthropic.com");
}
+ /**
+ * Verifies that a custom, non-default base URL is used in the request.
+ *
+ * This test uses a URL that differs from the default {@code https://api.anthropic.com},
+ * ensuring the conditional that selects between the configured URL and the default
+ * is correctly evaluated. If the conditional were negated, the request would be sent
+ * to the default URL instead of the custom one.
+ */
+ @Test
+ @DisplayName("should use custom non-default base URL when provided")
+ void customNonDefaultBaseUrlIsUsedInRequest() throws Exception {
+ String customBaseUrl = "http://internal.proxy.example.com:8080";
+ ProviderConfiguration configWithCustomUrl = new ProviderConfiguration(
+ API_MODEL, TIMEOUT_SECONDS, customBaseUrl, API_KEY);
+ AnthropicClaudeHttpAdapter adapterWithCustomUrl =
+ new AnthropicClaudeHttpAdapter(configWithCustomUrl, httpClient);
+
+ HttpResponse httpResponse = mockHttpResponse(200,
+ buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
+ doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
+
+ adapterWithCustomUrl.invoke(createTestRequest("p", "d"));
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(requestCaptor.capture(), any());
+ assertThat(requestCaptor.getValue().uri().toString())
+ .as("Custom non-default base URL must be used, not the default api.anthropic.com")
+ .startsWith("http://internal.proxy.example.com:8080");
+ }
+
+ /**
+ * Verifies that a port value of 0 in the base URL is not included in the endpoint URI.
+ *
+ * {@link java.net.URI#getPort()} returns {@code 0} when the URL explicitly specifies
+ * port 0. The endpoint builder must only include the port when it is greater than 0,
+ * not when it is equal to 0 or negative.
+ */
+ @Test
+ @DisplayName("should not include port 0 in the endpoint URI")
+ void buildEndpointUri_doesNotIncludePortZero() throws Exception {
+ ProviderConfiguration configWithPortZero = new ProviderConfiguration(
+ API_MODEL, TIMEOUT_SECONDS, "http://example.com:0", API_KEY);
+ AnthropicClaudeHttpAdapter adapterWithPortZero =
+ new AnthropicClaudeHttpAdapter(configWithPortZero, httpClient);
+
+ HttpResponse httpResponse = mockHttpResponse(200,
+ buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
+ doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
+
+ adapterWithPortZero.invoke(createTestRequest("p", "d"));
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(requestCaptor.capture(), any());
+ assertThat(requestCaptor.getValue().uri().toString())
+ .as("Port 0 must not appear in the endpoint URI")
+ .doesNotContain(":0");
+ }
+
// =========================================================================
// Helper methods
// =========================================================================
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java
index 575ad39..901c692 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java
@@ -555,6 +555,33 @@ class OpenAiHttpAdapterTest {
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT");
}
+ /**
+ * Verifies that a port value of 0 in the base URL is not included in the endpoint URI.
+ *
+ * {@link java.net.URI#getPort()} returns {@code 0} when the URL explicitly specifies
+ * port 0. The endpoint builder must only include the port when it is greater than 0,
+ * not when it is equal to 0 or negative.
+ */
+ @Test
+ @DisplayName("should not include port 0 in the endpoint URI")
+ void buildEndpointUri_doesNotIncludePortZero() throws Exception {
+ ProviderConfiguration configWithPortZero = new ProviderConfiguration(
+ API_MODEL, TIMEOUT_SECONDS, "http://example.com:0", API_KEY);
+ OpenAiHttpAdapter adapterWithPortZero = new OpenAiHttpAdapter(configWithPortZero, httpClient);
+
+ HttpResponse httpResponse = mockHttpResponse(200,
+ "{\"choices\":[{\"message\":{\"content\":\"test\"}}]}");
+ doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
+
+ adapterWithPortZero.invoke(createTestRequest("p", "d"));
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(requestCaptor.capture(), any());
+ assertThat(requestCaptor.getValue().uri().toString())
+ .as("Port 0 must not appear in the endpoint URI")
+ .doesNotContain(":0");
+ }
+
// Helper methods
/**
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java
index be0d742..3363451 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/LegacyConfigurationMigratorTest.java
@@ -348,4 +348,100 @@ class LegacyConfigurationMigratorTest {
assertTrue(migrated.containsKey("ai.provider.openai-compatible.model"),
"Migrated file must contain the new namespaced model key (complete write confirmed)");
}
+
+ // =========================================================================
+ // Tests: isLegacyForm – each individual legacy key triggers detection
+ // =========================================================================
+
+ /**
+ * A properties set containing only {@code api.baseUrl} (without {@code ai.provider.active})
+ * must be detected as legacy.
+ */
+ @Test
+ void isLegacyForm_detectedWhenOnlyBaseUrlPresent() {
+ Properties props = new Properties();
+ props.setProperty(LegacyConfigurationMigrator.LEGACY_BASE_URL, "https://api.example.com");
+ assertTrue(defaultMigrator().isLegacyForm(props),
+ "Properties with only api.baseUrl must be detected as legacy");
+ }
+
+ /**
+ * A properties set containing only {@code api.model} (without {@code ai.provider.active})
+ * must be detected as legacy.
+ */
+ @Test
+ void isLegacyForm_detectedWhenOnlyModelPresent() {
+ Properties props = new Properties();
+ props.setProperty(LegacyConfigurationMigrator.LEGACY_MODEL, "gpt-4o");
+ assertTrue(defaultMigrator().isLegacyForm(props),
+ "Properties with only api.model must be detected as legacy");
+ }
+
+ /**
+ * A properties set containing only {@code api.timeoutSeconds} (without {@code ai.provider.active})
+ * must be detected as legacy.
+ */
+ @Test
+ void isLegacyForm_detectedWhenOnlyTimeoutPresent() {
+ Properties props = new Properties();
+ props.setProperty(LegacyConfigurationMigrator.LEGACY_TIMEOUT, "30");
+ assertTrue(defaultMigrator().isLegacyForm(props),
+ "Properties with only api.timeoutSeconds must be detected as legacy");
+ }
+
+ /**
+ * A properties set containing only {@code api.key} (without {@code ai.provider.active})
+ * must be detected as legacy.
+ */
+ @Test
+ void isLegacyForm_detectedWhenOnlyApiKeyPresent() {
+ Properties props = new Properties();
+ props.setProperty(LegacyConfigurationMigrator.LEGACY_API_KEY, "sk-test");
+ assertTrue(defaultMigrator().isLegacyForm(props),
+ "Properties with only api.key must be detected as legacy");
+ }
+
+ // =========================================================================
+ // Tests: lineDefinesKey / generateMigratedContent – prefix-only match must not fire
+ // =========================================================================
+
+ /**
+ * A line whose key is a prefix of a legacy key (e.g. {@code api.baseUrlExtra}) must not
+ * be treated as defining the legacy key ({@code api.baseUrl}) and must survive migration
+ * unchanged while the actual legacy key is correctly replaced.
+ */
+ @Test
+ void generateMigratedContent_doesNotReplacePrefixMatchKey() {
+ String content = "api.baseUrlExtra=should-not-change\n"
+ + "api.baseUrl=https://real.example.com\n"
+ + "api.model=gpt-4o\n"
+ + "api.timeoutSeconds=30\n"
+ + "api.key=sk-real\n";
+
+ String migrated = defaultMigrator().generateMigratedContent(content);
+
+ assertTrue(migrated.contains("api.baseUrlExtra=should-not-change"),
+ "Line with key that is a prefix of a legacy key must not be modified");
+ assertTrue(migrated.contains("ai.provider.openai-compatible.baseUrl=https://real.example.com"),
+ "The actual legacy key api.baseUrl must be replaced with the namespaced key");
+ }
+
+ /**
+ * A line that defines a legacy key with no value (key only, no separator)
+ * must be recognized as defining that key and be replaced in migration.
+ */
+ @Test
+ void generateMigratedContent_handlesKeyWithoutValue() {
+ String content = "api.baseUrl\n"
+ + "api.model=gpt-4o\n"
+ + "api.timeoutSeconds=30\n"
+ + "api.key=sk-test\n";
+
+ String migrated = defaultMigrator().generateMigratedContent(content);
+
+ assertTrue(migrated.contains("ai.provider.openai-compatible.baseUrl"),
+ "Key-only line (no value, no separator) must still be recognized and replaced");
+ assertFalse(migrated.contains("api.baseUrl\n") || migrated.contains("api.baseUrl\r"),
+ "Original key-only line must not survive unchanged");
+ }
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
index bbd0804..00c30ad 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/configuration/MultiProviderConfigurationTest.java
@@ -416,4 +416,48 @@ class MultiProviderConfigurationTest {
config.claudeConfig().apiKey(),
"Inactive Claude config should still pick up its own env var");
}
+
+ // =========================================================================
+ // Tests: timeout validation
+ // =========================================================================
+
+ /**
+ * Active provider has timeout set to 0. Validation must fail and mention timeoutSeconds.
+ * This verifies that validateTimeoutSeconds is called and that the boundary is strictly
+ * positive (i.e. 0 is rejected, not just negative values).
+ */
+ @Test
+ void rejectsZeroTimeoutForActiveProvider() {
+ Properties props = fullOpenAiProperties();
+ props.setProperty("ai.provider.openai-compatible.timeoutSeconds", "0");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("timeoutSeconds"),
+ "Error message must reference timeoutSeconds");
+ }
+
+ /**
+ * Active Claude provider has timeout set to 0. Same invariant for the other provider family.
+ */
+ @Test
+ void rejectsZeroTimeoutForActiveClaudeProvider() {
+ Properties props = fullClaudeProperties();
+ props.setProperty("ai.provider.claude.timeoutSeconds", "0");
+
+ MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
+ MultiProviderConfiguration config = parser.parse(props);
+
+ InvalidStartConfigurationException ex = assertThrows(
+ InvalidStartConfigurationException.class,
+ () -> new MultiProviderConfigurationValidator().validate(config));
+
+ assertTrue(ex.getMessage().contains("timeoutSeconds"),
+ "Error message must reference timeoutSeconds");
+ }
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java
index 8df33eb..4be77e6 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sourcedocument/SourceDocumentCandidatesPortAdapterTest.java
@@ -209,4 +209,22 @@ class SourceDocumentCandidatesPortAdapterTest {
assertTrue(candidates.stream().allMatch(c -> c.uniqueIdentifier().endsWith(".pdf")),
"All candidates should be PDF files");
}
+
+ /**
+ * A directory whose name ends with {@code .pdf} must not be included as a candidate.
+ *
+ * The regular-file filter must exclude directories even when their name matches the
+ * PDF extension, so that only actual PDF files are returned.
+ */
+ @Test
+ void testLoadCandidates_DirectoryWithPdfExtensionIsExcluded() throws IOException {
+ Files.write(tempDir.resolve("real.pdf"), "content".getBytes());
+ Files.createDirectory(tempDir.resolve("looks-like.pdf"));
+
+ List candidates = adapter.loadCandidates();
+
+ assertEquals(1, candidates.size(),
+ "A directory with .pdf extension must not be included as a candidate");
+ assertEquals("real.pdf", candidates.get(0).uniqueIdentifier());
+ }
}
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
index 0806813..6010060 100644
--- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java
@@ -1,5 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -194,4 +195,40 @@ class SqliteUnitOfWorkAdapterTest {
});
}
+ /**
+ * Verifies that a document record written inside a successful transaction is persisted.
+ *
+ * This confirms that the actual write operation is invoked and the transaction is
+ * committed. Without an actual call to the underlying repository, the record would
+ * not be retrievable after the transaction completes.
+ */
+ @Test
+ void executeInTransaction_committedRecordIsRetrievable() {
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+ DocumentRecord record = new DocumentRecord(
+ fingerprint,
+ new SourceDocumentLocator("/source/commit-test.pdf"),
+ "commit-test.pdf",
+ ProcessingStatus.PROCESSING,
+ FailureCounters.zero(),
+ null,
+ null,
+ now,
+ now,
+ null,
+ null
+ );
+
+ SqliteDocumentRecordRepositoryAdapter docRepository =
+ new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
+
+ unitOfWorkAdapter.executeInTransaction(txOps -> txOps.createDocumentRecord(record));
+
+ var result = docRepository.findByFingerprint(fingerprint);
+ assertFalse(result instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
+ "Record must be persisted and retrievable after a successfully committed transaction");
+ }
+
}