M4 AP-005 Repository für Versuchshistorie implementieren
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* SQLite implementation of {@link ProcessingAttemptRepository}.
|
||||
* <p>
|
||||
* Provides CRUD operations for the processing attempt history (Versuchshistorie)
|
||||
* with explicit mapping between application types and the SQLite schema.
|
||||
* <p>
|
||||
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
|
||||
* confined to this class. No JDBC types appear in the port interface or in any
|
||||
* application/domain type.
|
||||
*
|
||||
* @since M4-AP-005
|
||||
*/
|
||||
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
|
||||
|
||||
/**
|
||||
* Constructs the adapter with the JDBC URL of the SQLite database file.
|
||||
*
|
||||
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
|
||||
* @throws NullPointerException if {@code jdbcUrl} is null
|
||||
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
|
||||
*/
|
||||
public SqliteProcessingAttemptRepositoryAdapter(String jdbcUrl) {
|
||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
|
||||
if (jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("jdbcUrl must not be blank");
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attempt number to assign to the <em>next</em> attempt for the given
|
||||
* fingerprint.
|
||||
* <p>
|
||||
* If no prior attempts exist for the fingerprint, returns 1.
|
||||
* Otherwise returns the current maximum attempt number plus 1.
|
||||
*
|
||||
* @param fingerprint the document identity; must not be null
|
||||
* @return the next monotonic attempt number; always >= 1
|
||||
* @throws DocumentPersistenceException if the query fails due to a technical error
|
||||
*/
|
||||
@Override
|
||||
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
|
||||
if (fingerprint == null) {
|
||||
throw new NullPointerException("fingerprint must not be null");
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
|
||||
FROM processing_attempt
|
||||
WHERE fingerprint = ?
|
||||
""";
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
|
||||
// Enable foreign key enforcement for this connection
|
||||
try (Statement pragmaStmt = connection.createStatement()) {
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
}
|
||||
|
||||
statement.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = statement.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt("next_attempt_number");
|
||||
} else {
|
||||
// This should not happen, but fallback to 1
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Failed to load next attempt number for fingerprint '" +
|
||||
fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists exactly one processing attempt record.
|
||||
* <p>
|
||||
* The {@link ProcessingAttempt#attemptNumber()} must have been obtained from
|
||||
* {@link #loadNextAttemptNumber(DocumentFingerprint)} in the same run to guarantee
|
||||
* monotonic ordering.
|
||||
*
|
||||
* @param attempt the attempt to persist; must not be null
|
||||
* @throws DocumentPersistenceException if the insert fails due to a technical error
|
||||
*/
|
||||
@Override
|
||||
public void save(ProcessingAttempt attempt) {
|
||||
if (attempt == null) {
|
||||
throw new NullPointerException("attempt must not be null");
|
||||
}
|
||||
|
||||
String sql = """
|
||||
INSERT INTO processing_attempt (
|
||||
fingerprint,
|
||||
run_id,
|
||||
attempt_number,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
failure_class,
|
||||
failure_message,
|
||||
retryable
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
|
||||
// Enable foreign key enforcement for this connection
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
|
||||
statement.setString(1, attempt.fingerprint().sha256Hex());
|
||||
statement.setString(2, attempt.runId().value());
|
||||
statement.setInt(3, attempt.attemptNumber());
|
||||
statement.setString(4, attempt.startedAt().toString());
|
||||
statement.setString(5, attempt.endedAt().toString());
|
||||
statement.setString(6, attempt.status().name());
|
||||
|
||||
// Handle nullable fields
|
||||
statement.setString(7, attempt.failureClass());
|
||||
statement.setString(8, attempt.failureMessage());
|
||||
statement.setBoolean(9, attempt.retryable());
|
||||
|
||||
int rowsAffected = statement.executeUpdate();
|
||||
if (rowsAffected != 1) {
|
||||
throw new DocumentPersistenceException(
|
||||
"Expected to insert 1 row but affected " + rowsAffected + " rows");
|
||||
}
|
||||
|
||||
logger.debug("Saved processing attempt #{} for fingerprint: {}",
|
||||
attempt.attemptNumber(), attempt.fingerprint().sha256Hex());
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Failed to save processing attempt #" + attempt.attemptNumber() +
|
||||
" for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all historised attempts for the given fingerprint, ordered by
|
||||
* {@link ProcessingAttempt#attemptNumber()} ascending.
|
||||
* <p>
|
||||
* Returns an empty list if no attempts have been recorded yet.
|
||||
* Intended for use in tests and diagnostics; not required on the primary batch path.
|
||||
*
|
||||
* @param fingerprint the document identity; must not be null
|
||||
* @return immutable list of attempts, ordered by attempt number; never null
|
||||
* @throws DocumentPersistenceException if the query fails due to a technical error
|
||||
*/
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
if (fingerprint == null) {
|
||||
throw new NullPointerException("fingerprint must not be null");
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
fingerprint,
|
||||
run_id,
|
||||
attempt_number,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
failure_class,
|
||||
failure_message,
|
||||
retryable
|
||||
FROM processing_attempt
|
||||
WHERE fingerprint = ?
|
||||
ORDER BY attempt_number ASC
|
||||
""";
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
Statement pragmaStmt = connection.createStatement();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
|
||||
// Enable foreign key enforcement for this connection
|
||||
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||
|
||||
statement.setString(1, fingerprint.sha256Hex());
|
||||
|
||||
try (ResultSet rs = statement.executeQuery()) {
|
||||
List<ProcessingAttempt> attempts = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
ProcessingAttempt attempt = mapResultSetToProcessingAttempt(rs);
|
||||
attempts.add(attempt);
|
||||
}
|
||||
return List.copyOf(attempts); // Return immutable copy
|
||||
}
|
||||
|
||||
} catch (SQLException e) {
|
||||
String message = "Failed to find processing attempts for fingerprint '" +
|
||||
fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||
logger.error(message, e);
|
||||
throw new DocumentPersistenceException(message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a ResultSet row to a ProcessingAttempt.
|
||||
*
|
||||
* @param rs the ResultSet positioned at the current row
|
||||
* @return the mapped ProcessingAttempt
|
||||
* @throws SQLException if reading from the ResultSet fails
|
||||
*/
|
||||
private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException {
|
||||
return new ProcessingAttempt(
|
||||
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||
new de.gecheckt.pdf.umbenenner.domain.model.RunId(rs.getString("run_id")),
|
||||
rs.getInt("attempt_number"),
|
||||
Instant.parse(rs.getString("started_at")),
|
||||
Instant.parse(rs.getString("ended_at")),
|
||||
de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.valueOf(rs.getString("status")),
|
||||
rs.getString("failure_class"),
|
||||
rs.getString("failure_message"),
|
||||
rs.getBoolean("retryable")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
|
||||
* <p>
|
||||
* Intended for logging and diagnostics only.
|
||||
*
|
||||
* @return the JDBC URL; never null or blank
|
||||
*/
|
||||
public String getJdbcUrl() {
|
||||
return jdbcUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.*;
|
||||
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 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 java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
|
||||
*
|
||||
* @since M4-AP-005
|
||||
*/
|
||||
class SqliteProcessingAttemptRepositoryAdapterTest {
|
||||
|
||||
private SqliteProcessingAttemptRepositoryAdapter repository;
|
||||
private String jdbcUrl;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Path dbFile = tempDir.resolve("test.db");
|
||||
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
|
||||
|
||||
// Initialize schema first
|
||||
SqliteSchemaInitializationAdapter schemaInitializer =
|
||||
new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||
schemaInitializer.initializeSchema();
|
||||
|
||||
repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullJdbcUrl() {
|
||||
assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("jdbcUrl");
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsBlankJdbcUrl() {
|
||||
assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(" "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("jdbcUrl");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getJdbcUrl_returnsConfiguredUrl() {
|
||||
String url = "jdbc:sqlite:/some/path/test.db";
|
||||
SqliteProcessingAttemptRepositoryAdapter adapter = new SqliteProcessingAttemptRepositoryAdapter(url);
|
||||
assertThat(adapter.getJdbcUrl()).isEqualTo(url);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// loadNextAttemptNumber
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadNextAttemptNumber_shouldReturnOne_whenNoAttemptsExist() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"0000000000000000000000000000000000000000000000000000000000000000");
|
||||
|
||||
// When
|
||||
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(nextAttemptNumber).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadNextAttemptNumber_shouldReturnNextNumber_whenAttemptsExist() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"1111111111111111111111111111111111111111111111111111111111111111");
|
||||
RunId runId = new RunId("test-run-1");
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
// Insert a document record first (FK constraint)
|
||||
insertDocumentRecord(fingerprint);
|
||||
|
||||
// Insert first attempt
|
||||
ProcessingAttempt firstAttempt = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId,
|
||||
1,
|
||||
now,
|
||||
now.plusSeconds(10),
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"IOException",
|
||||
"File not found",
|
||||
true
|
||||
);
|
||||
repository.save(firstAttempt);
|
||||
|
||||
// When
|
||||
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(nextAttemptNumber).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadNextAttemptNumber_shouldBeMonotonicAcrossMultipleCalls() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"2222222222222222222222222222222222222222222222222222222222222222");
|
||||
RunId runId = new RunId("test-run-2");
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
// Insert a document record first (FK constraint)
|
||||
insertDocumentRecord(fingerprint);
|
||||
|
||||
// Insert multiple attempts
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
ProcessingAttempt attempt = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId,
|
||||
i,
|
||||
now.plusSeconds(i * 10),
|
||||
now.plusSeconds(i * 10 + 5),
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"IOException",
|
||||
"File not found",
|
||||
true
|
||||
);
|
||||
repository.save(attempt);
|
||||
}
|
||||
|
||||
// When
|
||||
int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(nextAttemptNumber).isEqualTo(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadNextAttemptNumber_shouldRejectNullFingerprint() {
|
||||
assertThatThrownBy(() -> repository.loadNextAttemptNumber(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("fingerprint");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// save
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void save_shouldPersistProcessingAttempt_withAllFields() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"3333333333333333333333333333333333333333333333333333333333333333");
|
||||
RunId runId = new RunId("test-run-3");
|
||||
Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
|
||||
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
// Insert a document record first (FK constraint)
|
||||
insertDocumentRecord(fingerprint);
|
||||
|
||||
ProcessingAttempt attempt = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId,
|
||||
1,
|
||||
startedAt,
|
||||
endedAt,
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"IOException",
|
||||
"File not found",
|
||||
true
|
||||
);
|
||||
|
||||
// When
|
||||
repository.save(attempt);
|
||||
|
||||
// Then
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
|
||||
assertThat(attempts).hasSize(1);
|
||||
|
||||
ProcessingAttempt saved = attempts.get(0);
|
||||
assertThat(saved.fingerprint()).isEqualTo(fingerprint);
|
||||
assertThat(saved.runId()).isEqualTo(runId);
|
||||
assertThat(saved.attemptNumber()).isEqualTo(1);
|
||||
assertThat(saved.startedAt()).isEqualTo(startedAt);
|
||||
assertThat(saved.endedAt()).isEqualTo(endedAt);
|
||||
assertThat(saved.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
||||
assertThat(saved.failureClass()).isEqualTo("IOException");
|
||||
assertThat(saved.failureMessage()).isEqualTo("File not found");
|
||||
assertThat(saved.retryable()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_shouldPersistProcessingAttempt_withNullFailureFields() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"4444444444444444444444444444444444444444444444444444444444444444");
|
||||
RunId runId = new RunId("test-run-4");
|
||||
Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
|
||||
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
// Insert a document record first (FK constraint)
|
||||
insertDocumentRecord(fingerprint);
|
||||
|
||||
ProcessingAttempt attempt = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId,
|
||||
1,
|
||||
startedAt,
|
||||
endedAt,
|
||||
ProcessingStatus.SUCCESS,
|
||||
null, // null failure class
|
||||
null, // null failure message
|
||||
false // not retryable
|
||||
);
|
||||
|
||||
// When
|
||||
repository.save(attempt);
|
||||
|
||||
// Then
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
|
||||
assertThat(attempts).hasSize(1);
|
||||
|
||||
ProcessingAttempt saved = attempts.get(0);
|
||||
assertThat(saved.failureClass()).isNull();
|
||||
assertThat(saved.failureMessage()).isNull();
|
||||
assertThat(saved.retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_shouldRejectNullAttempt() {
|
||||
assertThatThrownBy(() -> repository.save(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("attempt");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// findAllByFingerprint
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void findAllByFingerprint_shouldReturnEmptyList_whenNoAttemptsExist() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"5555555555555555555555555555555555555555555555555555555555555555");
|
||||
|
||||
// When
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(attempts).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFingerprint_shouldReturnAllAttemptsOrderedByNumber() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"6666666666666666666666666666666666666666666666666666666666666666");
|
||||
RunId runId1 = new RunId("test-run-5");
|
||||
RunId runId2 = new RunId("test-run-6");
|
||||
Instant baseTime = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
// Insert a document record first (FK constraint)
|
||||
insertDocumentRecord(fingerprint);
|
||||
|
||||
// Insert attempts out of order to verify sorting
|
||||
ProcessingAttempt attempt3 = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId2,
|
||||
3,
|
||||
baseTime.plusSeconds(20),
|
||||
baseTime.plusSeconds(25),
|
||||
ProcessingStatus.FAILED_FINAL,
|
||||
"ContentError",
|
||||
"No text extractable",
|
||||
false
|
||||
);
|
||||
repository.save(attempt3);
|
||||
|
||||
ProcessingAttempt attempt1 = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId1,
|
||||
1,
|
||||
baseTime,
|
||||
baseTime.plusSeconds(5),
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"IOException",
|
||||
"File not found",
|
||||
true
|
||||
);
|
||||
repository.save(attempt1);
|
||||
|
||||
ProcessingAttempt attempt2 = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId1,
|
||||
2,
|
||||
baseTime.plusSeconds(10),
|
||||
baseTime.plusSeconds(15),
|
||||
ProcessingStatus.SKIPPED_ALREADY_PROCESSED,
|
||||
null,
|
||||
null,
|
||||
false
|
||||
);
|
||||
repository.save(attempt2);
|
||||
|
||||
// When
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(attempts).hasSize(3);
|
||||
assertThat(attempts.get(0).attemptNumber()).isEqualTo(1);
|
||||
assertThat(attempts.get(1).attemptNumber()).isEqualTo(2);
|
||||
assertThat(attempts.get(2).attemptNumber()).isEqualTo(3);
|
||||
|
||||
// Verify all fields
|
||||
ProcessingAttempt first = attempts.get(0);
|
||||
assertThat(first.runId()).isEqualTo(runId1);
|
||||
assertThat(first.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
||||
assertThat(first.failureClass()).isEqualTo("IOException");
|
||||
|
||||
ProcessingAttempt second = attempts.get(1);
|
||||
assertThat(second.status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
|
||||
assertThat(second.failureClass()).isNull();
|
||||
|
||||
ProcessingAttempt third = attempts.get(2);
|
||||
assertThat(third.status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||
assertThat(third.retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFingerprint_shouldReturnImmutableList() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"7777777777777777777777777777777777777777777777777777777777777777");
|
||||
|
||||
// When
|
||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
|
||||
|
||||
// Then
|
||||
assertThat(attempts).isEmpty();
|
||||
assertThatThrownBy(() -> attempts.add(null))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFingerprint_shouldRejectNullFingerprint() {
|
||||
assertThatThrownBy(() -> repository.findAllByFingerprint(null))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("fingerprint");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integration with document records (FK constraints)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void save_shouldFail_whenFingerprintDoesNotExistInDocumentRecord() {
|
||||
// Given
|
||||
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||
"8888888888888888888888888888888888888888888888888888888888888888");
|
||||
RunId runId = new RunId("test-run-7");
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
|
||||
ProcessingAttempt attempt = new ProcessingAttempt(
|
||||
fingerprint,
|
||||
runId,
|
||||
1,
|
||||
now,
|
||||
now.plusSeconds(10),
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
"IOException",
|
||||
"File not found",
|
||||
true
|
||||
);
|
||||
|
||||
// When / Then
|
||||
assertThatThrownBy(() -> repository.save(attempt))
|
||||
.isInstanceOf(DocumentPersistenceException.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void insertDocumentRecord(DocumentFingerprint fingerprint) {
|
||||
String sql = """
|
||||
INSERT INTO document_record (
|
||||
fingerprint,
|
||||
last_known_source_locator,
|
||||
last_known_source_file_name,
|
||||
overall_status,
|
||||
content_error_count,
|
||||
transient_error_count,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
var statement = connection.prepareStatement(sql)) {
|
||||
|
||||
statement.setString(1, fingerprint.sha256Hex());
|
||||
statement.setString(2, "/test/path/document.pdf");
|
||||
statement.setString(3, "document.pdf");
|
||||
statement.setString(4, ProcessingStatus.PROCESSING.name());
|
||||
statement.setInt(5, 0);
|
||||
statement.setInt(6, 0);
|
||||
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
statement.setString(7, now.toString());
|
||||
statement.setString(8, now.toString());
|
||||
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed to insert document record for testing", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user