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