diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java index 3a367f7..61aedad 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java @@ -39,19 +39,20 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort { @Override public void executeInTransaction(Consumer operations) { Objects.requireNonNull(operations, "operations must not be null"); - + Connection connection = null; try { connection = DriverManager.getConnection(jdbcUrl); connection.setAutoCommit(false); - + TransactionOperationsImpl txOps = new TransactionOperationsImpl(connection); operations.accept(txOps); - + connection.commit(); logger.debug("Transaction committed successfully"); - - } catch (SQLException e) { + + } catch (Exception e) { + // Rollback for ANY exception, not just SQLException if (connection != null) { try { connection.rollback(); @@ -60,6 +61,10 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort { logger.error("Failed to rollback transaction: {}", rollbackEx.getMessage(), rollbackEx); } } + // Re-throw as DocumentPersistenceException if not already + if (e instanceof DocumentPersistenceException) { + throw (DocumentPersistenceException) e; + } throw new DocumentPersistenceException("Transaction failed: " + e.getMessage(), e); } finally { if (connection != null) { 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 new file mode 100644 index 0000000..8479432 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapterTest.java @@ -0,0 +1,182 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; + +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; +import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +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; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link SqliteUnitOfWorkAdapter}. + *

+ * Tests verify transactional semantics: successful commits, rollback on first-write failure, + * rollback on second-write failure, and proper handling of DocumentPersistenceException. + * + * @since M4-AP-006 + */ +class SqliteUnitOfWorkAdapterTest { + + @TempDir + Path tempDir; + + private String jdbcUrl; + private SqliteUnitOfWorkAdapter unitOfWorkAdapter; + private SqliteSchemaInitializationAdapter schemaAdapter; + + @BeforeEach + void setUp() throws Exception { + Path dbFile = tempDir.resolve("test.db"); + jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/'); + + // Initialize schema + schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl); + schemaAdapter.initializeSchema(); + + // Create the unit of work adapter + unitOfWorkAdapter = new SqliteUnitOfWorkAdapter(jdbcUrl); + } + + /** + * Verifies DocumentPersistenceException is properly re-thrown + * without double-wrapping. + */ + @Test + void executeInTransaction_reThrowsDocumentPersistenceExceptionAsIs() { + DocumentPersistenceException originalException = + new DocumentPersistenceException("Original error"); + + DocumentPersistenceException thrownException = assertThrows( + DocumentPersistenceException.class, + () -> { + unitOfWorkAdapter.executeInTransaction(txOps -> { + throw originalException; + }); + } + ); + + assertSame(originalException, thrownException, + "DocumentPersistenceException should be re-thrown as-is, not wrapped"); + } + + /** + * Verifies rollback occurs when the first write fails. + * The transaction should not commit any data. + */ + @Test + void executeInTransaction_rollsBackWhenFirstWriteFails() { + DocumentFingerprint fingerprint = new DocumentFingerprint("2222222222222222222222222222222222222222222222222222222222222222"); + + // Create repositories for verification + SqliteDocumentRecordRepositoryAdapter docRepository = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + + assertThrows(DocumentPersistenceException.class, () -> { + unitOfWorkAdapter.executeInTransaction(txOps -> { + // First write: throw exception directly (simulates write failure) + throw new DocumentPersistenceException("Simulated first write failure"); + }); + }); + + // Verify no records were persisted (rollback occurred) + var lookupResult = docRepository.findByFingerprint(fingerprint); + assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown, + "No DocumentRecord should be persisted after rollback"); + } + + /** + * Verifies rollback occurs when the second write fails after the first write succeeds. + * The transaction should not commit the first write without the second. + */ + @Test + void executeInTransaction_rollsBackWhenSecondWriteFails() { + DocumentFingerprint fingerprint = new DocumentFingerprint("3333333333333333333333333333333333333333333333333333333333333333"); + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + DocumentRecord record = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/source/test.pdf"), + "test.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + now, + now + ); + + // Create repositories for verification + SqliteDocumentRecordRepositoryAdapter docRepository = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + + assertThrows(DocumentPersistenceException.class, () -> { + unitOfWorkAdapter.executeInTransaction(txOps -> { + // First write: succeeds + txOps.createDocumentRecord(record); + + // Second write: fails by throwing DocumentPersistenceException + throw new DocumentPersistenceException("Simulated write failure"); + }); + }); + + // Verify no records were persisted due to rollback + var lookupResult = docRepository.findByFingerprint(fingerprint); + assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown, + "DocumentRecord should be rolled back when second write fails"); + } + + /** + * Verifies rollback occurs when an arbitrary RuntimeException is thrown. + */ + @Test + void executeInTransaction_rollsBackOnArbitraryRuntimeException() { + DocumentFingerprint fingerprint = new DocumentFingerprint("4444444444444444444444444444444444444444444444444444444444444444"); + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + DocumentRecord record = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/source/test.pdf"), + "test.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + now, + now + ); + + RuntimeException customException = new RuntimeException("Custom runtime error"); + + // Create repositories for verification + SqliteDocumentRecordRepositoryAdapter docRepository = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + + DocumentPersistenceException thrownException = assertThrows( + DocumentPersistenceException.class, + () -> { + unitOfWorkAdapter.executeInTransaction(txOps -> { + txOps.createDocumentRecord(record); + throw customException; + }); + } + ); + + // Verify the exception was wrapped in DocumentPersistenceException + assertSame(customException, thrownException.getCause(), + "RuntimeException should be wrapped in DocumentPersistenceException"); + + // Verify rollback occurred + var lookupResult = docRepository.findByFingerprint(fingerprint); + assertTrue(lookupResult instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown, + "DocumentRecord should be rolled back on runtime exception"); + } +} 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 1596424..d885fc5 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 @@ -43,8 +43,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; *

    *
  1. Load and validate the startup configuration.
  2. *
  3. Resolve the run-lock file path (with default fallback).
  4. - *
  5. Initialise the SQLite schema (M4: before the batch document loop begins).
  6. - *
  7. Create and wire all ports and adapters, including the M4 persistence ports.
  8. + *
  9. Create and wire all ports and adapters via configured factories.
  10. *
  11. Start the CLI adapter and execute the batch use case.
  12. *
  13. Map the batch outcome to a process exit code.
  14. *
@@ -54,17 +53,17 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; *
  • {@code 0}: Batch run executed successfully; individual document failures do not * change the exit code as long as the run itself completed without a hard * infrastructure error.
  • - *
  • {@code 1}: Hard start, bootstrap, configuration, or schema-initialisation failure + *
  • {@code 1}: Hard start, bootstrap, configuration, or persistence failure * that prevented the run from beginning, or a critical infrastructure failure * during the run.
  • * * *

    M4 wiring

    *

    - * The production constructor wires the following M4 adapters: + * The production constructor wires the following M4 adapters via the UseCaseFactory: *

    *

    - * Schema initialisation is performed in {@link #run()} before the use case is created, - * using {@link SqliteSchemaInitializationAdapter}. + * Schema initialisation is performed by the UseCaseFactory when the use case is created, + * using {@link SqliteSchemaInitializationAdapter}. (AP-007 responsibility) */ public BootstrapRunner() { this.configPortFactory = PropertiesConfigurationPortAdapter::new; @@ -150,6 +149,11 @@ public class BootstrapRunner { this.validatorFactory = StartConfigurationValidator::new; this.useCaseFactory = (config, lock) -> { String jdbcUrl = buildJdbcUrl(config); + // AP-007: Initialize schema when the use case is created + if (config.sqliteFile() != null) { + PersistenceSchemaInitializationPort schemaPort = new SqliteSchemaInitializationAdapter(jdbcUrl); + schemaPort.initializeSchema(); + } FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); DocumentRecordRepository documentRecordRepository = new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); @@ -197,14 +201,11 @@ public class BootstrapRunner { * M4 additions: *

    * - * @return exit code: 0 for success, 1 for invalid configuration, schema failure, - * or unexpected bootstrap failure + * @return exit code: 0 for success, 1 for invalid configuration or unexpected bootstrap failure */ public int run() { LOG.info("Bootstrap flow started."); @@ -228,24 +229,21 @@ public class BootstrapRunner { } RunLockPort runLockPort = runLockPortFactory.create(lockFilePath); - // Step 5 (M4): Initialise the SQLite schema before the batch loop begins. - // A failure here is a hard start error → exit code 1. - initializeSchema(config); - - // Step 6: Create the batch run context + // Step 5: 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 7: Create the use case with the validated config and run lock. + // Step 6: 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 happens within the UseCaseFactory. (AP-007 responsibility) BatchRunProcessingUseCase useCase = useCaseFactory.create(config, runLockPort); - // Step 8: Create the CLI command adapter with the use case + // Step 7: Create the CLI command adapter with the use case SchedulerBatchCommand command = commandFactory.create(useCase); - // Step 9: Execute the command with the run context and handle the outcome + // Step 8: Execute the command with the run context and handle the outcome BatchRunOutcome outcome = command.run(runContext); // Mark run as completed @@ -271,8 +269,9 @@ public class BootstrapRunner { LOG.error("Configuration loading failed: {}", e.getMessage()); return 1; } catch (DocumentPersistenceException e) { - // Schema initialisation failed – hard start error - LOG.error("SQLite schema initialisation failed: {}", e.getMessage(), e); + // Persistence operation during startup failed – hard start error + // (e.g., schema initialisation in UseCaseFactory) + LOG.error("Persistence operation during startup failed: {}", e.getMessage(), e); return 1; } catch (Exception e) { LOG.error("Bootstrap failure during startup.", e); @@ -280,33 +279,6 @@ public class BootstrapRunner { } } - /** - * Initialises the M4 SQLite schema using the configured SQLite file path. - *

    - * This method is called once at startup, before the batch document loop begins. - * It uses the production {@link SqliteSchemaInitializationAdapter} directly because - * schema initialisation is a startup concern, not a per-document concern, and the - * {@link UseCaseFactory} abstraction is not the right place for it. - *

    - * If the {@code sqlite.file} configuration is null or blank, schema initialisation - * is skipped with a warning. This allows the existing test infrastructure (which - * uses the custom {@link UseCaseFactory}) to continue working without a real SQLite - * file. - * - * @param config the validated startup configuration - * @throws DocumentPersistenceException if schema initialisation fails - */ - private void initializeSchema(StartConfiguration config) { - if (config.sqliteFile() == null) { - LOG.warn("sqlite.file not configured – skipping schema initialisation."); - return; - } - String jdbcUrl = buildJdbcUrl(config); - PersistenceSchemaInitializationPort schemaPort = new SqliteSchemaInitializationAdapter(jdbcUrl); - schemaPort.initializeSchema(); - LOG.info("M4 SQLite schema initialised at: {}", jdbcUrl); - } - /** * Builds the JDBC URL for the SQLite database from the configured file path. *