1
0

M4 AP-006 Rollback-Semantik und Bootstrap-Scope bereinigen

This commit is contained in:
2026-04-03 09:32:47 +02:00
parent d61299f892
commit 30f070f2a6
3 changed files with 214 additions and 55 deletions

View File

@@ -39,19 +39,20 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
@Override
public void executeInTransaction(Consumer<TransactionOperations> 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) {

View File

@@ -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}.
* <p>
* 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");
}
}