V1.1 Änderungen

This commit is contained in:
2026-04-09 05:42:02 +02:00
parent 39800b6ea8
commit 5099ff4aca
44 changed files with 4912 additions and 957 deletions
@@ -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();
}
}
}
}