diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 4b0effe..8c340f8 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -16,6 +16,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPor 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.application.config.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; @@ -26,6 +27,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; @@ -40,6 +42,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; * Responsibilities: *
- * The production constructor wires the following M4 adapters via the UseCaseFactory: + * The production constructor wires the following M4 adapters: *
- * Schema initialisation is AP-007 responsibility, not performed in AP-006. + * Schema initialisation is performed once in {@link #run()} before the batch loop starts + * (AP-007). A {@link DocumentPersistenceException} during schema initialisation is treated + * as a hard startup failure and results in exit code 1. * - * @since M2 (extended in M4-AP-006) + * @since M2 (extended in M4-AP-006, M4-AP-007) */ public class BootstrapRunner { @@ -77,6 +83,7 @@ public class BootstrapRunner { private final ConfigurationPortFactory configPortFactory; private final RunLockPortFactory runLockPortFactory; private final ValidatorFactory validatorFactory; + private final SchemaInitializationPortFactory schemaInitPortFactory; private final UseCaseFactory useCaseFactory; private final CommandFactory commandFactory; @@ -104,6 +111,14 @@ public class BootstrapRunner { StartConfigurationValidator create(); } + /** + * Functional interface for creating a PersistenceSchemaInitializationPort from the JDBC URL. + */ + @FunctionalInterface + public interface SchemaInitializationPortFactory { + PersistenceSchemaInitializationPort create(String jdbcUrl); + } + /** * Functional interface for creating a BatchRunProcessingUseCase. *
@@ -134,17 +149,20 @@ public class BootstrapRunner { *
- * Schema initialisation is AP-007 responsibility and is NOT performed here. + * Schema initialisation is performed explicitly in {@link #run()} before the batch loop + * begins (AP-007). Failure during initialisation aborts the run with exit code 1. */ public BootstrapRunner() { this.configPortFactory = PropertiesConfigurationPortAdapter::new; this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.validatorFactory = StartConfigurationValidator::new; + this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new; this.useCaseFactory = (config, lock) -> { String jdbcUrl = buildJdbcUrl(config); FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); @@ -170,20 +188,23 @@ public class BootstrapRunner { /** * Creates the BootstrapRunner with custom factories for testing. * - * @param configPortFactory factory for creating ConfigurationPort instances - * @param runLockPortFactory factory for creating RunLockPort instances - * @param validatorFactory factory for creating StartConfigurationValidator instances - * @param useCaseFactory factory for creating BatchRunProcessingUseCase instances - * @param commandFactory factory for creating SchedulerBatchCommand instances + * @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 (AP-007) + * @param useCaseFactory factory for creating BatchRunProcessingUseCase instances + * @param commandFactory factory for creating SchedulerBatchCommand instances */ public BootstrapRunner(ConfigurationPortFactory configPortFactory, RunLockPortFactory runLockPortFactory, ValidatorFactory validatorFactory, + SchemaInitializationPortFactory schemaInitPortFactory, UseCaseFactory useCaseFactory, CommandFactory commandFactory) { this.configPortFactory = configPortFactory; this.runLockPortFactory = runLockPortFactory; this.validatorFactory = validatorFactory; + this.schemaInitPortFactory = schemaInitPortFactory; this.useCaseFactory = useCaseFactory; this.commandFactory = commandFactory; } @@ -191,13 +212,27 @@ public class BootstrapRunner { /** * Runs the application startup sequence. *
- * M4 additions: - *
+ * Document-level failures during the batch loop (step 6) are not startup failures and + * do not change the exit code as long as the run itself completes without a hard + * infrastructure error. * - * @return exit code: 0 for success, 1 for invalid configuration or unexpected bootstrap failure + * @return exit code: 0 for a technically completed run, 1 for any hard startup or + * bootstrap failure (configuration invalid, schema init failed, etc.) */ public int run() { LOG.info("Bootstrap flow started."); @@ -208,11 +243,18 @@ public class BootstrapRunner { // Step 2: Load configuration var config = configPort.loadConfiguration(); - // Step 3: Validate configuration + // Step 3: Validate configuration. + // Includes checking that sqlite.file parent directory exists or is creatable. StartConfigurationValidator validator = validatorFactory.create(); validator.validate(config); - // Step 4: Resolve lock file path – apply default if not configured + // Step 4 (M4-AP-007): Initialise SQLite persistence schema before the batch loop. + // Must happen once at startup; failure here is a hard bootstrap error → exit code 1. + String jdbcUrl = buildJdbcUrl(config); + PersistenceSchemaInitializationPort schemaInitPort = schemaInitPortFactory.create(jdbcUrl); + schemaInitPort.initializeSchema(); + + // Step 5: Resolve lock file path – apply default if not configured Path lockFilePath = config.runtimeLockFile(); if (lockFilePath == null || lockFilePath.toString().isBlank()) { lockFilePath = Paths.get("pdf-umbenenner.lock"); @@ -221,21 +263,20 @@ public class BootstrapRunner { } RunLockPort runLockPort = runLockPortFactory.create(lockFilePath); - // Step 5: Create the batch run context + // Step 6: Create the batch run context RunId runId = new RunId(UUID.randomUUID().toString()); BatchRunContext runContext = new BatchRunContext(runId, Instant.now()); LOG.info("Batch run started. RunId: {}", runId); - // Step 6: Create the use case with the validated config and run lock. + // Step 7: Create the use case with the validated config and run lock. // Config is passed directly; the use case does not re-read the properties file. // Adapters (source document port, PDF extraction port, M4 ports) are wired by the factory. - // Schema initialization is AP-007 responsibility, not performed in AP-006. BatchRunProcessingUseCase useCase = useCaseFactory.create(config, runLockPort); - // Step 7: Create the CLI command adapter with the use case + // Step 8: Create the CLI command adapter with the use case SchedulerBatchCommand command = commandFactory.create(useCase); - // Step 8: Execute the command with the run context and handle the outcome + // Step 9: Execute the command with the run context and handle the outcome BatchRunOutcome outcome = command.run(runContext); // Mark run as completed diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java index e9c23f9..e83f25a 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java @@ -25,5 +25,10 @@ * AP-005: CLI adapter and complete M2 object graph wiring. *
* AP-006: Wires FilesystemRunLockPortAdapter (adapter-out) from validated config; retired temporary no-op lock. + *
+ * AP-007: Adds SQLite persistence schema initialization at startup via + * {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}, + * ensuring the database is ready before document processing begins. Schema initialization failure + * is treated as a hard bootstrap error and causes exit code 1. */ package de.gecheckt.pdf.umbenenner.bootstrap; \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java index 86529d4..9062678 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java @@ -7,6 +7,8 @@ import de.gecheckt.pdf.umbenenner.application.config.StartConfigurationValidator import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; @@ -41,6 +43,7 @@ class BootstrapRunnerTest { () -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -65,6 +68,7 @@ class BootstrapRunnerTest { () -> mockConfigPort, lockFile -> new MockRunLockPort(), () -> failingValidator, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -84,6 +88,7 @@ class BootstrapRunnerTest { () -> failingConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -103,6 +108,7 @@ class BootstrapRunnerTest { () -> throwingConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -121,6 +127,7 @@ class BootstrapRunnerTest { () -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> failingUseCase, useCase -> new SchedulerBatchCommand(useCase) ); @@ -140,6 +147,7 @@ class BootstrapRunnerTest { () -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> lockUnavailableUseCase, useCase -> new SchedulerBatchCommand(useCase) ); @@ -191,6 +199,7 @@ class BootstrapRunnerTest { return new MockRunLockPort(); }, StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> new MockRunBatchProcessingUseCase(true), useCase -> new SchedulerBatchCommand(useCase) ); @@ -219,6 +228,7 @@ class BootstrapRunnerTest { () -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, + jdbcUrl -> new MockSchemaInitializationPort(), (config, lock) -> useCaseWithDocumentFailures, SchedulerBatchCommand::new ); @@ -233,6 +243,36 @@ class BootstrapRunnerTest { assertNotNull(runner, "Default constructor should create a valid BootstrapRunner"); } + /** + * AP-007: Hard startup failure test — schema initialization failure must be treated as + * a startup error and result in exit code 1. This verifies that + * {@link DocumentPersistenceException} thrown from + * {@link PersistenceSchemaInitializationPort#initializeSchema()} is correctly caught + * and handled as a bootstrap failure. + */ + @Test + void run_returnsOneWhenSchemaInitializationFails() throws Exception { + ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); + + BootstrapRunner runner = new BootstrapRunner( + () -> mockConfigPort, + lockFile -> new MockRunLockPort(), + StartConfigurationValidator::new, + jdbcUrl -> new PersistenceSchemaInitializationPort() { + @Override + public void initializeSchema() { + throw new DocumentPersistenceException("Simulated schema initialization failure"); + } + }, + (config, lock) -> new MockRunBatchProcessingUseCase(true), + useCase -> new SchedulerBatchCommand(useCase) + ); + + int exitCode = runner.run(); + + assertEquals(1, exitCode, "Schema initialization failure should return exit code 1"); + } + // ------------------------------------------------------------------------- // Mocks // ------------------------------------------------------------------------- @@ -306,4 +346,11 @@ class BootstrapRunnerTest { @Override public void release() { } } + + private static class MockSchemaInitializationPort implements PersistenceSchemaInitializationPort { + @Override + public void initializeSchema() { + // Success by default; can be subclassed to throw exceptions for testing + } + } }