1
0

M5 komplett umgesetzt

This commit is contained in:
2026-04-07 12:26:14 +02:00
parent 506f5ac32e
commit 9874fdb1ba
51 changed files with 5960 additions and 536 deletions

View File

@@ -4,6 +4,7 @@ import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -16,8 +17,20 @@ import java.util.List;
* and basic path existence checks. Throws {@link InvalidStartConfigurationException}
* if any validation rule fails.
* <p>
* Supports injected source folder validation for testability
* Supports injected source and target folder validation for testability
* (allows mocking of platform-dependent filesystem checks).
*
* <h2>Target folder validation</h2>
* <p>
* The target folder is validated as "present or technically creatable":
* <ul>
* <li>If it already exists: must be a directory and writable.</li>
* <li>If it does not yet exist: the {@link TargetFolderChecker} attempts to create it
* via {@code Files.createDirectories}. Creation failure is a hard validation error.</li>
* </ul>
* <p>
* This behaviour ensures the target write path is technically usable before any
* document processing begins, without requiring the operator to create the folder manually.
*/
public class StartConfigurationValidator {
@@ -48,22 +61,64 @@ public class StartConfigurationValidator {
String checkSourceFolder(Path path);
}
/**
* Abstraction for target folder existence, creatability, and write-access checks.
* <p>
* Separates filesystem operations from validation logic to enable
* platform-independent unit testing (mocking) of write-access and creation edge cases.
* <p>
* The default implementation attempts to create the folder via
* {@code Files.createDirectories} if it does not yet exist, then verifies it is a
* directory and writable. Tests can substitute alternative implementations.
*/
@FunctionalInterface
public interface TargetFolderChecker {
/**
* Checks target folder usability and returns a validation error message, or null if valid.
* <p>
* Checks (in order):
* <ol>
* <li>If folder does not exist: attempt to create it via {@code createDirectories}.</li>
* <li>Is a directory.</li>
* <li>Is writable (required for the file-copy write path).</li>
* </ol>
*
* @param path the target folder path
* @return error message string, or null if all checks pass
*/
String checkTargetFolder(Path path);
}
private final SourceFolderChecker sourceFolderChecker;
private final TargetFolderChecker targetFolderChecker;
/**
* Creates a validator with the default source folder checker (NIO-based).
* Creates a validator with default NIO-based source and target folder checkers.
*/
public StartConfigurationValidator() {
this(new DefaultSourceFolderChecker());
this(new DefaultSourceFolderChecker(), new DefaultTargetFolderChecker());
}
/**
* Creates a validator with a custom source folder checker (primarily for testing).
* Uses the default NIO-based target folder checker.
*
* @param sourceFolderChecker the checker to use (must not be null)
* @param sourceFolderChecker the source folder checker to use (must not be null)
*/
public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker) {
this(sourceFolderChecker, new DefaultTargetFolderChecker());
}
/**
* Creates a validator with custom source and target folder checkers (primarily for testing).
*
* @param sourceFolderChecker the source folder checker to use (must not be null)
* @param targetFolderChecker the target folder checker to use (must not be null)
*/
public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker,
TargetFolderChecker targetFolderChecker) {
this.sourceFolderChecker = sourceFolderChecker;
this.targetFolderChecker = targetFolderChecker;
}
/**
@@ -130,7 +185,14 @@ public class StartConfigurationValidator {
}
private void validateTargetFolder(Path targetFolder, List<String> errors) {
validateRequiredExistingDirectory(targetFolder, "target.folder", errors);
if (targetFolder == null) {
errors.add("- target.folder: must not be null");
return;
}
String checkError = targetFolderChecker.checkTargetFolder(targetFolder);
if (checkError != null) {
errors.add(checkError);
}
}
private void validateSqliteFile(Path sqliteFile, List<String> errors) {
@@ -321,4 +383,38 @@ public class StartConfigurationValidator {
return null; // All checks passed
}
}
/**
* Default NIO-based implementation of {@link TargetFolderChecker}.
* <p>
* Validates that the target folder is present and writable for the file-copy write path.
* If the folder does not yet exist, creation is attempted via {@code Files.createDirectories}.
* <p>
* This satisfies the "present or technically creatable" requirement: the folder need not
* exist before the application starts, but must be reachable at startup time.
* <p>
* This separation allows unit tests to inject alternative implementations
* that control the outcome of write-access or creation checks without relying on actual
* filesystem permissions (which are platform-dependent).
*/
private static class DefaultTargetFolderChecker implements TargetFolderChecker {
@Override
public String checkTargetFolder(Path path) {
if (!Files.exists(path)) {
try {
Files.createDirectories(path);
} catch (IOException e) {
return "- target.folder: path does not exist and could not be created: "
+ path + " (" + e.getMessage() + ")";
}
}
if (!Files.isDirectory(path)) {
return "- target.folder: path is not a directory: " + path;
}
if (!Files.isWritable(path)) {
return "- target.folder: directory is not writable: " + path;
}
return null; // All checks passed
}
}
}

View File

@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.clock;
import java.time.Instant;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
/**
* System clock implementation of {@link ClockPort}.
* <p>
* Returns the current wall-clock time from the JVM system clock.
* Intended for production use; tests should inject a controlled clock implementation.
*/
public class SystemClockAdapter implements ClockPort {
/**
* Returns the current system time as an {@link Instant}.
*
* @return the current UTC instant; never null
*/
@Override
public Instant now() {
return Instant.now();
}
}

View File

@@ -76,7 +76,7 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
}
String sql = """
SELECT
SELECT
last_known_source_locator,
last_known_source_file_name,
overall_status,
@@ -85,7 +85,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
last_failure_instant,
last_success_instant,
created_at,
updated_at
updated_at,
last_target_path,
last_target_file_name
FROM document_record
WHERE fingerprint = ?
""";
@@ -146,8 +148,10 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
last_failure_instant,
last_success_instant,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
updated_at,
last_target_path,
last_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -163,6 +167,8 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
statement.setString(8, instantToString(record.lastSuccessInstant()));
statement.setString(9, instantToString(record.createdAt()));
statement.setString(10, instantToString(record.updatedAt()));
statement.setString(11, record.lastTargetPath());
statement.setString(12, record.lastTargetFileName());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -205,7 +211,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
transient_error_count = ?,
last_failure_instant = ?,
last_success_instant = ?,
updated_at = ?
updated_at = ?,
last_target_path = ?,
last_target_file_name = ?
WHERE fingerprint = ?
""";
@@ -220,7 +228,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
statement.setString(6, instantToString(record.lastFailureInstant()));
statement.setString(7, instantToString(record.lastSuccessInstant()));
statement.setString(8, instantToString(record.updatedAt()));
statement.setString(9, record.fingerprint().sha256Hex());
statement.setString(9, record.lastTargetPath());
statement.setString(10, record.lastTargetFileName());
statement.setString(11, record.fingerprint().sha256Hex());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -260,7 +270,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
stringToInstant(rs.getString("last_failure_instant")),
stringToInstant(rs.getString("last_success_instant")),
stringToInstant(rs.getString("created_at")),
stringToInstant(rs.getString("updated_at"))
stringToInstant(rs.getString("updated_at")),
rs.getString("last_target_path"),
rs.getString("last_target_file_name")
);
}

View File

@@ -6,7 +6,9 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -17,13 +19,21 @@ import org.apache.logging.log4j.Logger;
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.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* 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.
* including all AI traceability fields added during schema evolution.
* <p>
* <strong>Schema compatibility:</strong> This adapter writes all columns including
* the AI traceability columns. When reading rows that were written before schema
* evolution, those columns contain {@code NULL} and are mapped to {@code null}
* in the Java record.
* <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
@@ -65,9 +75,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/
@Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
@@ -78,7 +86,6 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
try (Statement pragmaStmt = connection.createStatement()) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
}
@@ -89,34 +96,27 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
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();
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.
* Persists exactly one processing attempt record including all AI traceability fields.
*
* @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");
}
Objects.requireNonNull(attempt, "attempt must not be null");
String sql = """
INSERT INTO processing_attempt (
@@ -128,15 +128,24 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
status,
failure_class,
failure_message,
retryable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
retryable,
model_name,
prompt_identifier,
processed_page_count,
sent_character_count,
ai_raw_response,
ai_reasoning,
resolved_date,
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
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());
@@ -145,11 +154,22 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
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());
setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable());
// AI traceability fields
setNullableString(statement, 10, attempt.modelName());
setNullableString(statement, 11, attempt.promptIdentifier());
setNullableInteger(statement, 12, attempt.processedPageCount());
setNullableInteger(statement, 13, attempt.sentCharacterCount());
setNullableString(statement, 14, attempt.aiRawResponse());
setNullableString(statement, 15, attempt.aiReasoning());
setNullableString(statement, 16,
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
setNullableString(statement, 17,
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 18, attempt.validatedTitle());
setNullableString(statement, 19, attempt.finalTargetFileName());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -157,12 +177,12 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
"Expected to insert 1 row but affected " + rowsAffected + " rows");
}
logger.debug("Saved processing attempt #{} for fingerprint: {}",
attempt.attemptNumber(), attempt.fingerprint().sha256Hex());
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();
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);
}
@@ -171,31 +191,22 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
/**
* 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
* @return immutable list of attempts; never null
* @throws DocumentPersistenceException if the query fails
*/
@Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = """
SELECT
fingerprint,
run_id,
attempt_number,
started_at,
ended_at,
status,
failure_class,
failure_message,
retryable
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
@@ -205,67 +216,151 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
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);
attempts.add(mapResultSetToProcessingAttempt(rs));
}
return List.copyOf(attempts); // Return immutable copy
return List.copyOf(attempts);
}
} catch (SQLException e) {
String message = "Failed to find processing attempts for fingerprint '" +
fingerprint.sha256Hex() + "': " + e.getMessage();
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.
* Returns the most recent attempt with status {@code PROPOSAL_READY} for the given
* fingerprint, or {@code null} if no such attempt exists.
* <p>
* This is the <em>leading source</em> for the naming proposal: the most recent
* {@code PROPOSAL_READY} attempt carries the validated date, title, and reasoning
* that subsequent processing steps consume.
*
* @param rs the ResultSet positioned at the current row
* @return the mapped ProcessingAttempt
* @throws SQLException if reading from the ResultSet fails
* @param fingerprint the document identity; must not be null
* @return the most recent {@code PROPOSAL_READY} attempt, or {@code null}
* @throws DocumentPersistenceException if the query fails
*/
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = """
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
AND status = 'PROPOSAL_READY'
ORDER BY attempt_number DESC
LIMIT 1
""";
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return mapResultSetToProcessingAttempt(rs);
}
return null;
}
} catch (SQLException e) {
String message = "Failed to find latest PROPOSAL_READY attempt for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
// -------------------------------------------------------------------------
// Mapping helpers
// -------------------------------------------------------------------------
private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException {
String resolvedDateStr = rs.getString("resolved_date");
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
String dateSourceStr = rs.getString("date_source");
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
Integer processedPageCount = (Integer) getNullableInt(rs, "processed_page_count");
Integer sentCharacterCount = (Integer) getNullableInt(rs, "sent_character_count");
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new de.gecheckt.pdf.umbenenner.domain.model.RunId(rs.getString("run_id")),
new 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")),
ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable")
rs.getBoolean("retryable"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
sentCharacterCount,
rs.getString("ai_raw_response"),
rs.getString("ai_reasoning"),
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name")
);
}
// -------------------------------------------------------------------------
// JDBC nullable helpers
// -------------------------------------------------------------------------
private static void setNullableString(PreparedStatement stmt, int index, String value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.VARCHAR);
} else {
stmt.setString(index, value);
}
}
private static void setNullableInteger(PreparedStatement stmt, int index, Integer value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.INTEGER);
} else {
stmt.setInt(index, value);
}
}
private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* <p>
* Intended for logging and diagnostics only.
* Returns the JDBC URL this adapter uses.
*
* @return the JDBC URL; never null or blank
*/
public String getJdbcUrl() {
return jdbcUrl;
}
/**
* Gets a connection to the database.
* <p>
* This method can be overridden by subclasses to provide a shared connection.
*
* @return a new database connection
* @throws SQLException if the connection cannot be established
* Returns a JDBC connection. May be overridden in tests to provide shared connections.
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);

View File

@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Objects;
@@ -16,9 +17,8 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* SQLite implementation of {@link PersistenceSchemaInitializationPort}.
* <p>
* Creates or verifies the two-level persistence schema in the configured SQLite
* database file. All DDL uses {@code IF NOT EXISTS} semantics, making the operation
* fully idempotent: calling {@link #initializeSchema()} on an already-initialised
* database succeeds without error and without modifying existing data.
* database file, and performs a controlled schema evolution from an earlier schema
* version to the current one.
*
* <h2>Two-level schema</h2>
* <p>The schema consists of exactly two tables:
@@ -30,10 +30,29 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* the master record via fingerprint.</li>
* </ol>
*
* <h2>Schema evolution</h2>
* <p>
* When upgrading from an earlier schema, this adapter uses idempotent
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
* exist are silently skipped, making the evolution safe to run on both fresh and existing
* databases. The current evolution adds:
* <ul>
* <li>AI-traceability columns to {@code processing_attempt}</li>
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* </ul>
*
* <h2>M4→current-schema status migration</h2>
* <p>
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
* the AI naming pipeline processes them in the next run. Terminal negative states
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
*
* <h2>Initialisation timing</h2>
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch
* document processing loop begins. It is wired by the bootstrap module and called
* explicitly through the port. There is no lazy or deferred initialisation.
* document processing loop begins.
*
* <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
@@ -44,34 +63,17 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
// -------------------------------------------------------------------------
// DDL — document_record table
// -------------------------------------------------------------------------
/**
* DDL for the document master record table.
* <p>
* <strong>Columns (mandatory fields):</strong>
* <ul>
* <li>{@code id} — internal surrogate primary key (auto-increment).</li>
* <li>{@code fingerprint} — SHA-256 hex string; unique natural key; never null.</li>
* <li>{@code last_known_source_locator} — opaque locator value (file path string);
* never null.</li>
* <li>{@code last_known_source_file_name} — human-readable file name for logging;
* never null.</li>
* <li>{@code overall_status} — current processing status as enum name string;
* never null.</li>
* <li>{@code content_error_count} — count of deterministic content errors;
* default 0; never negative.</li>
* <li>{@code transient_error_count} — count of transient technical errors;
* default 0; never negative.</li>
* <li>{@code last_failure_instant} — ISO-8601 UTC timestamp of the most recent
* failure; nullable.</li>
* <li>{@code last_success_instant} — ISO-8601 UTC timestamp of the successful
* processing; nullable.</li>
* <li>{@code created_at} — ISO-8601 UTC timestamp of record creation; never null.</li>
* <li>{@code updated_at} — ISO-8601 UTC timestamp of the most recent update;
* never null.</li>
* </ul>
* <p>
* <strong>Not included (M5+ fields):</strong> target path, target file name,
* AI-related fields.
* Columns: id (PK), fingerprint (unique), last_known_source_locator,
* last_known_source_file_name, overall_status, content_error_count,
* transient_error_count, last_failure_instant, last_success_instant,
* created_at, updated_at.
*/
private static final String DDL_CREATE_DOCUMENT_RECORD = """
CREATE TABLE IF NOT EXISTS document_record (
@@ -90,36 +92,18 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
)
""";
// -------------------------------------------------------------------------
// DDL — processing_attempt table (base schema, without AI traceability cols)
// -------------------------------------------------------------------------
/**
* DDL for the processing attempt history table.
* DDL for the base processing attempt history table.
* <p>
* <strong>Columns (mandatory fields):</strong>
* <ul>
* <li>{@code id} — internal surrogate primary key (auto-increment).</li>
* <li>{@code fingerprint} — foreign key reference to
* {@code document_record.fingerprint}; never null.</li>
* <li>{@code run_id} — identifier of the batch run; never null.</li>
* <li>{@code attempt_number} — monotonically increasing per fingerprint, starting
* at 1; never null. The unique constraint on {@code (fingerprint, attempt_number)}
* enforces uniqueness per document.</li>
* <li>{@code started_at} — ISO-8601 UTC timestamp of attempt start; never null.</li>
* <li>{@code ended_at} — ISO-8601 UTC timestamp of attempt end; never null.</li>
* <li>{@code status} — outcome status as enum name string; never null.</li>
* <li>{@code failure_class} — short failure classification; nullable (null for
* success and skip attempts).</li>
* <li>{@code failure_message} — human-readable failure description; nullable
* (null for success and skip attempts).</li>
* <li>{@code retryable} — 1 if the failure is retryable in a later run, 0 otherwise;
* never null. Always 0 for success and skip attempts.</li>
* </ul>
* Base columns (present in all schema versions): id, fingerprint, run_id,
* attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
* <p>
* <strong>Skip attempts:</strong> Skip statuses ({@code SKIPPED_ALREADY_PROCESSED},
* {@code SKIPPED_FINAL_FAILURE}) are stored as regular rows with {@code retryable = 0}
* and null failure fields.
* <p>
* <strong>Not included (M5+ fields):</strong> model name, prompt identifier,
* AI raw response, AI reasoning, resolved date, date source, final title,
* final target file name.
* AI traceability columns are added separately via {@code ALTER TABLE} to support
* idempotent evolution from earlier schemas.
*/
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
CREATE TABLE IF NOT EXISTS processing_attempt (
@@ -140,6 +124,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
)
""";
// -------------------------------------------------------------------------
// DDL — indexes
// -------------------------------------------------------------------------
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
@@ -155,14 +143,69 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
+ "ON document_record (overall_status)";
// -------------------------------------------------------------------------
// DDL — columns added to processing_attempt via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
{"prompt_identifier", "TEXT"},
{"processed_page_count", "INTEGER"},
{"sent_character_count", "INTEGER"},
{"ai_raw_response", "TEXT"},
{"ai_reasoning", "TEXT"},
{"resolved_date", "TEXT"},
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// DDL — columns added to document_record via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code document_record}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
{"last_target_path", "TEXT"},
{"last_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// M4→current-schema status migration
// -------------------------------------------------------------------------
/**
* Migrates earlier positive intermediate states in {@code document_record} that were
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
* so the AI naming pipeline processes them in the next run.
* <p>
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
* This migration is idempotent.
*/
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
UPDATE document_record
SET overall_status = 'READY_FOR_AI',
updated_at = datetime('now')
WHERE overall_status = 'SUCCESS'
AND NOT EXISTS (
SELECT 1 FROM processing_attempt pa
WHERE pa.fingerprint = document_record.fingerprint
AND pa.status = 'PROPOSAL_READY'
)
""";
private final String jdbcUrl;
/**
* Constructs the adapter with the JDBC URL of the SQLite database file.
* <p>
* The JDBC URL must be in the form {@code jdbc:sqlite:/path/to/file.db}.
* The file and its parent directories need not exist at construction time;
* SQLite creates them when the connection is first opened.
*
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null
@@ -177,26 +220,22 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
}
/**
* Creates or verifies the persistence schema in the SQLite database.
* Creates or verifies the persistence schema and performs schema evolution and
* status migration.
* <p>
* Executes the following DDL statements in order:
* Execution order:
* <ol>
* <li>Enable foreign key enforcement ({@code PRAGMA foreign_keys = ON})</li>
* <li>Create {@code document_record} table (if not exists)</li>
* <li>Create {@code processing_attempt} table (if not exists)</li>
* <li>Create indexes on {@code processing_attempt.fingerprint},
* {@code processing_attempt.run_id}, and
* {@code document_record.overall_status}</li>
* <li>Enable foreign key enforcement.</li>
* <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create all indexes (if not exist).</li>
* <li>Add AI-traceability columns to {@code processing_attempt} (idempotent evolution).</li>
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol>
* <p>
* All statements use {@code IF NOT EXISTS} semantics. Calling this method on an
* already-initialised database is safe and produces no changes.
* <p>
* <strong>Timing:</strong> Must be called once at program startup, before the
* batch document processing loop begins.
* All steps are safe to run on both fresh and existing databases.
*
* @throws DocumentPersistenceException if the schema cannot be created or verified
* due to a JDBC or SQLite error
* @throws DocumentPersistenceException if any DDL or migration step fails
*/
@Override
public void initializeSchema() {
@@ -211,7 +250,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
statement.execute(DDL_CREATE_DOCUMENT_RECORD);
logger.debug("Table 'document_record' created or already present.");
// Level 2: processing attempt history
// Level 2: processing attempt history (base columns only)
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
logger.debug("Table 'processing_attempt' created or already present.");
@@ -221,7 +260,20 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
statement.execute(DDL_IDX_RECORD_STATUS);
logger.debug("Indexes created or already present.");
logger.info("M4 SQLite schema initialisation completed successfully.");
// Schema evolution: add AI-traceability + target-copy columns (idempotent)
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
// Status migration: earlier positive intermediate state → READY_FOR_AI
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
if (migrated > 0) {
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
migrated);
} else {
logger.debug("Status migration: no documents required migration.");
}
logger.info("SQLite schema initialisation and migration completed successfully.");
} catch (SQLException e) {
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
@@ -231,9 +283,43 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* Idempotently adds the given columns to the specified table.
* <p>
* Intended for logging and diagnostics only.
* For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
* statement is executed. Columns that already exist are silently skipped.
*
* @param connection an open JDBC connection to the database
* @param tableName the name of the table to evolve
* @param columns array of {@code [column_name, column_type]} pairs to add
* @throws SQLException if a column addition fails for a reason other than duplicate column
*/
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
throws SQLException {
java.util.Set<String> existingColumns = new java.util.HashSet<>();
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
while (rs.next()) {
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String[] col : columns) {
String columnName = col[0];
String columnType = col[1];
if (!existingColumns.contains(columnName.toLowerCase())) {
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
try (Statement stmt = connection.createStatement()) {
stmt.execute(alterSql);
}
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
} else {
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
columnName, tableName);
}
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
*
* @return the JDBC URL; never null or blank
*/

View File

@@ -0,0 +1,141 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetcopy;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
/**
* Filesystem-based implementation of {@link TargetFileCopyPort}.
* <p>
* Copies a source PDF to the configured target folder using a two-step approach:
* <ol>
* <li>Write the source content to a temporary file in the target folder.</li>
* <li>Rename/move the temporary file to the final resolved filename.</li>
* </ol>
* The atomic-move option is attempted first. If the filesystem does not support atomic
* moves (e.g., across different volumes), a standard move is used as a fallback.
*
* <h2>Source integrity</h2>
* <p>
* The source file is never modified, moved, or deleted. Only a copy is created.
*
* <h2>Temporary file naming</h2>
* <p>
* The temporary file uses the suffix {@code .tmp} appended to the resolved filename
* and is placed in the same target folder. This ensures the final rename is typically
* an intra-filesystem operation, maximising atomicity.
*
* <h2>Architecture boundary</h2>
* <p>
* All NIO operations are confined to this adapter. No {@code Path} or {@code File}
* types appear in the port interface.
*/
public class FilesystemTargetFileCopyAdapter implements TargetFileCopyPort {
private static final Logger logger = LogManager.getLogger(FilesystemTargetFileCopyAdapter.class);
private final Path targetFolderPath;
/**
* Creates the adapter for the given target folder.
*
* @param targetFolderPath the target folder path; must not be null
* @throws NullPointerException if {@code targetFolderPath} is null
*/
public FilesystemTargetFileCopyAdapter(Path targetFolderPath) {
this.targetFolderPath = Objects.requireNonNull(targetFolderPath, "targetFolderPath must not be null");
}
/**
* Copies the source document to the target folder under the given resolved filename.
* <p>
* The copy is performed via a temporary file ({@code resolvedFilename + ".tmp"}) in
* the target folder followed by a move/rename to the final name.
* <p>
* If any step fails, a best-effort cleanup of the temporary file is attempted
* before returning the failure result.
*
* @param sourceLocator opaque locator identifying the source file; must not be null
* @param resolvedFilename the final filename in the target folder; must not be null or blank
* @return {@link TargetFileCopySuccess} on success, or
* {@link TargetFileCopyTechnicalFailure} on any failure
*/
@Override
public TargetFileCopyResult copyToTarget(SourceDocumentLocator sourceLocator, String resolvedFilename) {
Objects.requireNonNull(sourceLocator, "sourceLocator must not be null");
Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
Path sourcePath = Paths.get(sourceLocator.value());
Path finalTargetPath = targetFolderPath.resolve(resolvedFilename);
Path tempTargetPath = targetFolderPath.resolve(resolvedFilename + ".tmp");
boolean tempCreated = false;
try {
// Step 1: Copy source to temporary file in target folder
Files.copy(sourcePath, tempTargetPath, StandardCopyOption.REPLACE_EXISTING);
tempCreated = true;
logger.debug("Copied source '{}' to temporary file '{}'.",
sourceLocator.value(), tempTargetPath.getFileName());
// Step 2: Atomic move/rename to final target filename
moveToFinalTarget(tempTargetPath, finalTargetPath);
logger.debug("Target copy completed: '{}'.", resolvedFilename);
return new TargetFileCopySuccess();
} catch (Exception e) {
String message = "Failed to copy source '" + sourceLocator.value()
+ "' to target '" + resolvedFilename + "': " + e.getMessage();
logger.error(message, e);
boolean cleaned = tempCreated && tryDeletePath(tempTargetPath);
return new TargetFileCopyTechnicalFailure(message, cleaned);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Moves the temporary file to the final target path.
* Attempts an atomic move first; falls back to a standard move if the filesystem
* does not support atomic moves.
*/
private void moveToFinalTarget(Path tempPath, Path finalPath) throws IOException {
try {
Files.move(tempPath, finalPath, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
logger.debug("Atomic move not supported, falling back to standard move.");
Files.move(tempPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Best-effort deletion of a path. Returns {@code true} if deletion succeeded
* or the file did not exist; {@code false} if an exception occurred.
*/
private boolean tryDeletePath(Path path) {
try {
Files.deleteIfExists(path);
return true;
} catch (IOException e) {
logger.warn("Best-effort cleanup: could not delete temporary file '{}': {}",
path, e.getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,140 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/**
* Filesystem-based implementation of {@link TargetFolderPort}.
* <p>
* Resolves unique filenames for the configured target folder by checking for existing
* files and appending a numeric collision-avoidance suffix when necessary.
*
* <h2>Duplicate resolution algorithm</h2>
* <p>
* Given a base name such as {@code 2024-01-15 - Rechnung.pdf}, the adapter checks:
* <ol>
* <li>{@code 2024-01-15 - Rechnung.pdf} — if free, return it.</li>
* <li>{@code 2024-01-15 - Rechnung(1).pdf} — if free, return it.</li>
* <li>{@code 2024-01-15 - Rechnung(2).pdf} — and so on.</li>
* </ol>
* The suffix is inserted immediately before {@code .pdf}.
* The 20-character base-title limit does not apply to the suffix.
*
* <h2>Architecture boundary</h2>
* <p>
* All NIO operations are confined to this adapter. No {@code Path} or {@code File} types
* appear in the port interface.
*/
public class FilesystemTargetFolderAdapter implements TargetFolderPort {
private static final Logger logger = LogManager.getLogger(FilesystemTargetFolderAdapter.class);
/** Maximum number of duplicate suffixes attempted before giving up. */
private static final int MAX_SUFFIX_ATTEMPTS = 9999;
private final Path targetFolderPath;
/**
* Creates the adapter for the given target folder.
*
* @param targetFolderPath the target folder path; must not be null
* @throws NullPointerException if {@code targetFolderPath} is null
*/
public FilesystemTargetFolderAdapter(Path targetFolderPath) {
this.targetFolderPath = Objects.requireNonNull(targetFolderPath, "targetFolderPath must not be null");
}
/**
* Returns the absolute string representation of the target folder path.
* <p>
* Used by the application layer as an opaque target-folder locator for persistence.
*
* @return absolute path string of the target folder; never null or blank
*/
@Override
public String getTargetFolderLocator() {
return targetFolderPath.toAbsolutePath().toString();
}
/**
* Resolves the first available unique filename in the target folder for the given base name.
* <p>
* Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
* directly before {@code .pdf} until a free name is found.
*
* @param baseName the desired filename including {@code .pdf} extension;
* must not be null or blank
* @return a {@link ResolvedTargetFilename} with the first available name, or a
* {@link TargetFolderTechnicalFailure} if folder access fails
*/
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
Objects.requireNonNull(baseName, "baseName must not be null");
try {
// Try without suffix first
if (!Files.exists(targetFolderPath.resolve(baseName))) {
logger.debug("Resolved target filename without suffix: '{}'", baseName);
return new ResolvedTargetFilename(baseName);
}
// Determine split point: everything before the final ".pdf"
if (!baseName.toLowerCase().endsWith(".pdf")) {
return new TargetFolderTechnicalFailure(
"Base name does not end with .pdf: '" + baseName + "'");
}
String nameWithoutExt = baseName.substring(0, baseName.length() - 4);
// Try (1), (2), ...
for (int i = 1; i <= MAX_SUFFIX_ATTEMPTS; i++) {
String candidate = nameWithoutExt + "(" + i + ").pdf";
if (!Files.exists(targetFolderPath.resolve(candidate))) {
logger.debug("Resolved target filename with suffix ({}): '{}'", i, candidate);
return new ResolvedTargetFilename(candidate);
}
}
return new TargetFolderTechnicalFailure(
"Too many duplicate files for base name '" + baseName
+ "': checked up to suffix (" + MAX_SUFFIX_ATTEMPTS + ")");
} catch (Exception e) {
String message = "Failed to check target folder for duplicate resolution: " + e.getMessage();
logger.error(message, e);
return new TargetFolderTechnicalFailure(message);
}
}
/**
* Best-effort deletion of a file in the target folder.
* <p>
* Used for rollback after a successful copy when subsequent persistence fails.
* Never throws; all exceptions are caught and logged at warn level.
*
* @param resolvedFilename the filename (not full path) to delete; must not be null
*/
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
try {
boolean deleted = Files.deleteIfExists(targetFolderPath.resolve(resolvedFilename));
if (deleted) {
logger.debug("Best-effort rollback: deleted target file '{}'.", resolvedFilename);
} else {
logger.debug("Best-effort rollback: target file '{}' did not exist.", resolvedFilename);
}
} catch (IOException e) {
logger.warn("Best-effort rollback: could not delete target file '{}': {}",
resolvedFilename, e.getMessage());
}
}
}

View File

@@ -464,14 +464,16 @@ class StartConfigurationValidatorTest {
}
@Test
void validate_failsWhenTargetFolderDoesNotExist() throws Exception {
void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception {
// target.folder is "anlegbar" (creatable): parent tempDir exists, folder itself does not.
// The validator must create the folder and accept the configuration.
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
tempDir.resolve("nonexistent"),
tempDir.resolve("nonexistent-target"),
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
@@ -486,11 +488,43 @@ class StartConfigurationValidatorTest {
"test-api-key"
);
assertDoesNotThrow(() -> validator.validate(config),
"Validator must accept a target folder that does not yet exist but can be created");
assertTrue(Files.isDirectory(tempDir.resolve("nonexistent-target")),
"Target folder must have been created by the validator");
}
@Test
void validate_failsWhenTargetFolderCannotBeCreated() {
// Inject a TargetFolderChecker that simulates a creation failure.
StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator(
path -> null, // source folder checker always passes
path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)"
);
StartConfiguration config = new StartConfiguration(
tempDir.resolve("source"),
tempDir.resolve("uncreatable-target"),
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("prompt.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
() -> validatorWithFailingChecker.validate(config)
);
assertTrue(exception.getMessage().contains("target.folder: path does not exist"));
assertTrue(exception.getMessage().contains("target.folder: path does not exist and could not be created"),
"Error message must indicate that the target folder could not be created");
}
@Test

View File

@@ -74,7 +74,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS)
Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
);
// When
@@ -111,7 +113,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS),
Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS)
Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS),
null,
null
);
repository.create(initialRecord);
@@ -127,7 +131,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
now,
initialRecord.createdAt(),
now
now,
null,
null
);
// When
@@ -160,7 +166,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS)
Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
);
repository.create(record1);
@@ -174,7 +182,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS)
Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
);
// When / Then
@@ -196,7 +206,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS)
Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
);
// When / Then
@@ -221,7 +233,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
now.minusSeconds(120),
now.minusSeconds(120)
now.minusSeconds(120),
null,
null
);
repository.create(initialRecord);
@@ -236,7 +250,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant,
null,
now.minusSeconds(120),
failureInstant
failureInstant,
null,
null
);
repository.update(failedFinalRecord);
@@ -269,7 +285,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
createdAt,
createdAt
createdAt,
null,
null
);
repository.create(initialRecord);
@@ -284,7 +302,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant,
null,
createdAt,
failureInstant
failureInstant,
null,
null
);
// When
@@ -321,7 +341,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
firstFailureAt,
null,
createdAt,
firstFailureAt
firstFailureAt,
null,
null
);
repository.create(initialRecord);
@@ -336,7 +358,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
secondFailureAt,
null,
createdAt,
secondFailureAt
secondFailureAt,
null,
null
);
// When
@@ -369,7 +393,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
createdAt,
createdAt
createdAt,
null,
null
);
repository.create(initialRecord);
@@ -384,7 +410,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant,
null,
createdAt,
failureInstant
failureInstant,
null,
null
);
// When
@@ -439,7 +467,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
now,
now
now,
null,
null
);
repository.create(record);
@@ -467,7 +497,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
now.minusSeconds(60),
null,
now,
now
now,
null,
null
);
repository.create(record);
@@ -495,7 +527,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, // lastFailureInstant is null
null, // lastSuccessInstant is null
now,
now
now,
null,
null
);
repository.create(record);
@@ -509,6 +543,76 @@ class SqliteDocumentRecordRepositoryAdapterTest {
assertThat(known.record().lastSuccessInstant()).isNull();
}
@Test
void create_and_update_shouldPersistAndReadTargetPathAndTargetFileName() {
// Given: create a record with null target fields initially
DocumentFingerprint fingerprint = new DocumentFingerprint(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
DocumentRecord initialRecord = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/doc.pdf"),
"doc.pdf",
ProcessingStatus.PROCESSING,
FailureCounters.zero(),
null, null,
now, now,
null, null
);
repository.create(initialRecord);
// Update with target path and filename
DocumentRecord successRecord = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/doc.pdf"),
"doc.pdf",
ProcessingStatus.SUCCESS,
FailureCounters.zero(),
null, now,
now, now,
"/target/folder",
"2026-01-15 - Rechnung.pdf"
);
// When
repository.update(successRecord);
DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint);
// Then
assertThat(result).isInstanceOf(DocumentTerminalSuccess.class);
DocumentRecord found = ((DocumentTerminalSuccess) result).record();
assertThat(found.lastTargetPath()).isEqualTo("/target/folder");
assertThat(found.lastTargetFileName()).isEqualTo("2026-01-15 - Rechnung.pdf");
}
@Test
void update_shouldPersistNullTargetFields_whenNotYetCopied() {
// Given: a record with null target path and filename (not yet in SUCCESS)
DocumentFingerprint fingerprint = new DocumentFingerprint(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
DocumentRecord record = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/pending.pdf"),
"pending.pdf",
ProcessingStatus.FAILED_RETRYABLE,
new FailureCounters(0, 1),
now, null,
now, now,
null, null
);
repository.create(record);
// When
DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint);
// Then
assertThat(result).isInstanceOf(DocumentKnownProcessable.class);
DocumentRecord found = ((DocumentKnownProcessable) result).record();
assertThat(found.lastTargetPath()).isNull();
assertThat(found.lastTargetFileName()).isNull();
}
@Test
void update_shouldPreserveCreatedAtTimestamp() {
// Given: create with specific createdAt
@@ -526,7 +630,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
null,
createdAt, // Much older createdAt
createdAt
createdAt,
null,
null
);
repository.create(initialRecord);
@@ -540,7 +646,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null,
now,
createdAt, // createdAt should remain unchanged
now
now,
null,
null
);
// When

View File

@@ -8,6 +8,7 @@ import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
@@ -17,14 +18,16 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
*
* @since M4-AP-005
* <p>
* Covers base attempt persistence, AI traceability field round-trips,
* proposal-ready lookup, and non-AI-attempt status storability.
*/
class SqliteProcessingAttemptRepositoryAdapterTest {
@@ -101,7 +104,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
insertDocumentRecord(fingerprint);
// Insert first attempt
ProcessingAttempt firstAttempt = new ProcessingAttempt(
ProcessingAttempt firstAttempt = ProcessingAttempt.withoutAiFields(
fingerprint,
runId,
1,
@@ -134,7 +137,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert multiple attempts
for (int i = 1; i <= 5; i++) {
ProcessingAttempt attempt = new ProcessingAttempt(
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint,
runId,
i,
@@ -178,7 +181,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint,
runId,
1,
@@ -221,7 +224,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint,
runId,
1,
@@ -283,7 +286,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
insertDocumentRecord(fingerprint);
// Insert attempts out of order to verify sorting
ProcessingAttempt attempt3 = new ProcessingAttempt(
ProcessingAttempt attempt3 = ProcessingAttempt.withoutAiFields(
fingerprint,
runId2,
3,
@@ -296,7 +299,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
);
repository.save(attempt3);
ProcessingAttempt attempt1 = new ProcessingAttempt(
ProcessingAttempt attempt1 = ProcessingAttempt.withoutAiFields(
fingerprint,
runId1,
1,
@@ -309,7 +312,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
);
repository.save(attempt1);
ProcessingAttempt attempt2 = new ProcessingAttempt(
ProcessingAttempt attempt2 = ProcessingAttempt.withoutAiFields(
fingerprint,
runId1,
2,
@@ -368,6 +371,388 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
.hasMessageContaining("fingerprint");
}
// -------------------------------------------------------------------------
// AI traceability fields — round-trip persistence
// -------------------------------------------------------------------------
@Test
void save_persistsAllAiTraceabilityFields_andFindAllReadsThemBack() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
RunId runId = new RunId("ai-run-1");
Instant startedAt = Instant.now().minusSeconds(30).truncatedTo(ChronoUnit.MICROS);
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate resolvedDate = LocalDate.of(2026, 3, 15);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, startedAt, endedAt,
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4o", "prompt-v1.txt",
5, 1234,
"{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
"Invoice date found.",
resolvedDate, DateSource.AI_PROVIDED,
"Stromabrechnung",
null
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.modelName()).isEqualTo("gpt-4o");
assertThat(result.promptIdentifier()).isEqualTo("prompt-v1.txt");
assertThat(result.processedPageCount()).isEqualTo(5);
assertThat(result.sentCharacterCount()).isEqualTo(1234);
assertThat(result.aiRawResponse()).contains("Stromabrechnung");
assertThat(result.aiReasoning()).isEqualTo("Invoice date found.");
assertThat(result.resolvedDate()).isEqualTo(resolvedDate);
assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(result.validatedTitle()).isEqualTo("Stromabrechnung");
}
@Test
void save_persistsAiFieldsWithFallbackDateSource() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
RunId runId = new RunId("ai-run-2");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate fallbackDate = LocalDate.of(2026, 4, 7);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"claude-sonnet-4-6", "prompt-v2.txt",
3, 800,
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
"No date in document.",
fallbackDate, DateSource.FALLBACK_CURRENT,
"Kontoauszug",
null
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
assertThat(result.resolvedDate()).isEqualTo(fallbackDate);
}
@Test
void save_persistsNullAiFields_whenNoAiCallWasMade() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
RunId runId = new RunId("no-ai-run");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, runId, 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE,
"NoTextError", "No extractable text", true
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.modelName()).isNull();
assertThat(result.promptIdentifier()).isNull();
assertThat(result.processedPageCount()).isNull();
assertThat(result.sentCharacterCount()).isNull();
assertThat(result.aiRawResponse()).isNull();
assertThat(result.aiReasoning()).isNull();
assertThat(result.resolvedDate()).isNull();
assertThat(result.dateSource()).isNull();
assertThat(result.validatedTitle()).isNull();
}
// -------------------------------------------------------------------------
// findLatestProposalReadyAttempt
// -------------------------------------------------------------------------
@Test
void findLatestProposalReadyAttempt_returnsNull_whenNoAttemptsExist() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNull();
}
@Test
void findLatestProposalReadyAttempt_returnsNull_whenNoProposalReadyAttemptExists() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-x"), 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
);
repository.save(attempt);
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNull();
}
@Test
void findLatestProposalReadyAttempt_returnsSingleProposalReadyAttempt() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 2, 1);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4o", "prompt-v1.txt", 2, 500,
"{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
"Found.", date, DateSource.AI_PROVIDED, "Rechnung",
null
);
repository.save(attempt);
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNotNull();
assertThat(result.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(result.validatedTitle()).isEqualTo("Rechnung");
assertThat(result.resolvedDate()).isEqualTo(date);
assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
}
@Test
void findLatestProposalReadyAttempt_returnsLatest_whenMultipleExist() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"1111111111111111111111111111111111111111111111111111111111111112");
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
// First PROPOSAL_READY attempt
repository.save(new ProcessingAttempt(
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-a", "prompt-v1.txt", 1, 100,
"{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
null
));
// Subsequent FAILED attempt
repository.save(ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-2"), 2,
base.plusSeconds(10), base.plusSeconds(11),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
));
// Second PROPOSAL_READY attempt (newer)
repository.save(new ProcessingAttempt(
fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-b", "prompt-v2.txt", 2, 200,
"{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
null
));
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNotNull();
assertThat(result.attemptNumber()).isEqualTo(3);
assertThat(result.validatedTitle()).isEqualTo("TitelZwei");
assertThat(result.modelName()).isEqualTo("model-b");
}
@Test
void save_persistsFinalTargetFileName_forSuccessAttempt() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"4444444444444444444444444444444444444444444444444444444444444445");
RunId runId = new RunId("success-run");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 1, 15);
String expectedFileName = "2026-01-15 - Rechnung.pdf";
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, now, now.plusSeconds(3),
ProcessingStatus.SUCCESS,
null, null, false,
"gpt-4", "prompt-v1.txt", 2, 600,
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
"Invoice.",
date, DateSource.AI_PROVIDED,
"Rechnung",
expectedFileName
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).finalTargetFileName()).isEqualTo(expectedFileName);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.SUCCESS);
}
@Test
void save_persistsNullFinalTargetFileName_forNonSuccessAttempt() {
// finalTargetFileName must remain null for PROPOSAL_READY and non-SUCCESS attempts
DocumentFingerprint fingerprint = new DocumentFingerprint(
"5555555555555555555555555555555555555555555555555555555555555556");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4", "prompt-v1.txt", 1, 200,
"{}", "reason",
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
"Kontoauszug",
null // no target filename yet
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).finalTargetFileName()).isNull();
}
@Test
void save_proposalAttemptNotOverwrittenBySubsequentSuccessAttempt() {
// Verifies that the leading PROPOSAL_READY attempt remains unchanged when
// a subsequent SUCCESS attempt is added (no update, only new insert).
DocumentFingerprint fingerprint = new DocumentFingerprint(
"6666666666666666666666666666666666666666666666666666666666666667");
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 2, 10);
insertDocumentRecord(fingerprint);
// First attempt: PROPOSAL_READY
ProcessingAttempt proposalAttempt = new ProcessingAttempt(
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-a", "prompt-v1.txt", 3, 700,
"{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
);
repository.save(proposalAttempt);
// Second attempt: SUCCESS (target copy completed)
ProcessingAttempt successAttempt = new ProcessingAttempt(
fingerprint, new RunId("run-1"), 2,
base.plusSeconds(5), base.plusSeconds(6),
ProcessingStatus.SUCCESS,
null, null, false,
null, null, null, null, null, null,
null, null, null,
"2026-02-10 - Bescheid.pdf"
);
repository.save(successAttempt);
// Both attempts must be present
List<ProcessingAttempt> all = repository.findAllByFingerprint(fingerprint);
assertThat(all).hasSize(2);
// The original PROPOSAL_READY attempt must remain unchanged
ProcessingAttempt first = all.get(0);
assertThat(first.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(first.validatedTitle()).isEqualTo("Bescheid");
assertThat(first.finalTargetFileName()).isNull();
// The SUCCESS attempt carries the final filename
ProcessingAttempt second = all.get(1);
assertThat(second.status()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(second.finalTargetFileName()).isEqualTo("2026-02-10 - Bescheid.pdf");
}
@Test
void findLatestProposalReadyAttempt_rejectsNullFingerprint() {
assertThatThrownBy(() -> repository.findLatestProposalReadyAttempt(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("fingerprint");
}
// -------------------------------------------------------------------------
// READY_FOR_AI and PROPOSAL_READY status storability
// -------------------------------------------------------------------------
@Test
void save_canPersistReadyForAiStatus() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"2222222222222222222222222222222222222222222222222222222222222223");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-r"), 1, now, now.plusSeconds(1),
ProcessingStatus.READY_FOR_AI, null, null, false
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.READY_FOR_AI);
}
@Test
void save_canPersistProposalReadyStatus() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"3333333333333333333333333333333333333333333333333333333333333334");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-x", "prompt-v1.txt", 1, 50,
"{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
null
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
}
// -------------------------------------------------------------------------
// Integration with document records (FK constraints)
// -------------------------------------------------------------------------
@@ -380,7 +765,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
RunId runId = new RunId("test-run-7");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt(
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint,
runId,
1,

View File

@@ -18,12 +18,12 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
/**
* Unit tests for {@link SqliteSchemaInitializationAdapter}.
* Tests for {@link SqliteSchemaInitializationAdapter}.
* <p>
* Verifies that the M4 two-level schema is created correctly, that the operation
* is idempotent, and that invalid configuration is rejected.
*
* @since M4-AP-003
* Verifies that the two-level schema is created correctly, that schema evolution
* (idempotent addition of AI traceability columns) works, that the idempotent
* status migration of earlier positive intermediate states to {@code READY_FOR_AI}
* is correct, and that invalid configuration is rejected.
*/
class SqliteSchemaInitializationAdapterTest {
@@ -87,7 +87,9 @@ class SqliteSchemaInitializationAdapterTest {
"last_failure_instant",
"last_success_instant",
"created_at",
"updated_at"
"updated_at",
"last_target_path",
"last_target_file_name"
);
}
@@ -107,7 +109,17 @@ class SqliteSchemaInitializationAdapterTest {
"status",
"failure_class",
"failure_message",
"retryable"
"retryable",
"model_name",
"prompt_identifier",
"processed_page_count",
"sent_character_count",
"ai_raw_response",
"ai_reasoning",
"resolved_date",
"date_source",
"validated_title",
"final_target_file_name"
);
}
@@ -239,6 +251,130 @@ class SqliteSchemaInitializationAdapterTest {
}
}
// -------------------------------------------------------------------------
// Schema evolution — AI traceability columns
// -------------------------------------------------------------------------
@Test
void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
throws SQLException {
// Simulate a pre-evolution schema: create the base tables without AI columns
String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0
)
""");
}
// Running initializeSchema on the existing base schema must succeed (evolution)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
assertThat(columns).contains(
"model_name", "prompt_identifier", "processed_page_count",
"sent_character_count", "ai_raw_response", "ai_reasoning",
"resolved_date", "date_source", "validated_title");
}
// -------------------------------------------------------------------------
// Status migration — earlier positive intermediate state → READY_FOR_AI
// -------------------------------------------------------------------------
@Test
void initializeSchema_migrates_legacySuccessWithoutProposal_toReadyForAi(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Insert a document with SUCCESS status and no PROPOSAL_READY attempt
String fp = "d".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run schema initialisation again (migration step runs every time)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_migration_isIdempotent(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_idempotent_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "e".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run migration twice — must not corrupt data or throw
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_doesNotMigrate_successWithProposalReadyAttempt(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_proposal_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "f".repeat(64);
// SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("SUCCESS");
}
@Test
void initializeSchema_doesNotMigrate_terminalFailureStates(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_failure_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fpRetryable = "1".repeat(64);
String fpFinal = "2".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
}
// -------------------------------------------------------------------------
// Error handling
// -------------------------------------------------------------------------
@@ -286,4 +422,47 @@ class SqliteSchemaInitializationAdapterTest {
}
return columns;
}
private static void insertDocumentRecordWithStatus(String jdbcUrl, String fingerprint,
String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, '/src', 'doc.pdf', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
""")) {
ps.setString(1, fingerprint);
ps.setString(2, status);
ps.executeUpdate();
}
}
private static void insertAttemptWithStatus(String jdbcUrl, String fingerprint,
String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
""")) {
ps.setString(1, fingerprint);
ps.setString(2, status);
ps.executeUpdate();
}
}
private static String readOverallStatus(String jdbcUrl, String fingerprint) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement(
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
ps.setString(1, fingerprint);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getString("overall_status");
}
throw new IllegalStateException("No document record found for fingerprint: " + fingerprint);
}
}
}
}

View File

@@ -112,7 +112,9 @@ class SqliteUnitOfWorkAdapterTest {
null,
null,
now,
now
now,
null,
null
);
// Create repositories for verification
@@ -151,7 +153,9 @@ class SqliteUnitOfWorkAdapterTest {
null,
null,
now,
now
now,
null,
null
);
RuntimeException customException = new RuntimeException("Custom runtime error");

View File

@@ -0,0 +1,229 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetcopy;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link FilesystemTargetFileCopyAdapter}.
* <p>
* Covers the happy path (copy via temp file and final move), source integrity,
* technical failure cases, and cleanup after failure.
*/
class FilesystemTargetFileCopyAdapterTest {
@TempDir
Path sourceFolder;
@TempDir
Path targetFolder;
private FilesystemTargetFileCopyAdapter adapter;
@BeforeEach
void setUp() {
adapter = new FilesystemTargetFileCopyAdapter(targetFolder);
}
// -------------------------------------------------------------------------
// Happy path successful copy
// -------------------------------------------------------------------------
@Test
void copyToTarget_success_returnsTargetFileCopySuccess() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "PDF content");
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
assertThat(result).isInstanceOf(TargetFileCopySuccess.class);
}
@Test
void copyToTarget_success_targetFileCreatedWithCorrectContent() throws IOException {
byte[] content = "PDF content bytes".getBytes();
Path sourceFile = sourceFolder.resolve("invoice.pdf");
Files.write(sourceFile, content);
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
Path targetFile = targetFolder.resolve(resolvedFilename);
assertThat(targetFile).exists();
assertThat(Files.readAllBytes(targetFile)).isEqualTo(content);
}
@Test
void copyToTarget_success_sourceFileRemainsUnchanged() throws IOException {
byte[] originalContent = "original PDF content".getBytes();
Path sourceFile = sourceFolder.resolve("source.pdf");
Files.write(sourceFile, originalContent);
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
// Source must remain completely unchanged
assertThat(Files.readAllBytes(sourceFile)).isEqualTo(originalContent);
assertThat(sourceFile).exists();
}
@Test
void copyToTarget_success_noTempFileRemainsInTargetFolder() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
String resolvedFilename = "2026-04-07 - Bescheid.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
// The .tmp file must not remain after a successful copy
Path tempFile = targetFolder.resolve(resolvedFilename + ".tmp");
assertThat(tempFile).doesNotExist();
}
@Test
void copyToTarget_success_finalFileNameIsResolved() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "data");
String resolvedFilename = "2026-03-05 - Kontoauszug.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
assertThat(targetFolder.resolve(resolvedFilename)).exists();
}
// -------------------------------------------------------------------------
// Technical failure source file does not exist
// -------------------------------------------------------------------------
@Test
void copyToTarget_sourceDoesNotExist_returnsTargetFileCopyTechnicalFailure() {
String nonExistentSource = sourceFolder.resolve("nonexistent.pdf").toAbsolutePath().toString();
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
}
@Test
void copyToTarget_sourceDoesNotExist_failureContainsSourcePath() {
String nonExistentSource = sourceFolder.resolve("nonexistent.pdf").toAbsolutePath().toString();
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
TargetFileCopyTechnicalFailure failure = (TargetFileCopyTechnicalFailure) result;
assertThat(failure.errorMessage()).contains(nonExistentSource);
}
// -------------------------------------------------------------------------
// Technical failure target folder does not exist
// -------------------------------------------------------------------------
@Test
void copyToTarget_targetFolderDoesNotExist_returnsTargetFileCopyTechnicalFailure()
throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
Path nonExistentTargetFolder = targetFolder.resolve("nonexistent-subfolder");
FilesystemTargetFileCopyAdapter adapterWithMissingFolder =
new FilesystemTargetFileCopyAdapter(nonExistentTargetFolder);
TargetFileCopyResult result = adapterWithMissingFolder.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Cleanup after failure no temp file left
// -------------------------------------------------------------------------
@Test
void copyToTarget_sourceDoesNotExist_noTempFileLeftInTargetFolder() {
String nonExistentSource = sourceFolder.resolve("missing.pdf").toAbsolutePath().toString();
String resolvedFilename = "2026-01-01 - Test.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
resolvedFilename);
// Even though the copy failed, no temp file should remain
Path tempFile = targetFolder.resolve(resolvedFilename + ".tmp");
assertThat(tempFile).doesNotExist();
}
// -------------------------------------------------------------------------
// TargetFileCopyTechnicalFailure semantics
// -------------------------------------------------------------------------
@Test
void copyToTarget_failure_messageIsNonNull() {
String nonExistentSource = sourceFolder.resolve("ghost.pdf").toAbsolutePath().toString();
TargetFileCopyTechnicalFailure failure = (TargetFileCopyTechnicalFailure)
adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Test.pdf");
assertThat(failure.errorMessage()).isNotNull();
}
// -------------------------------------------------------------------------
// Null guards
// -------------------------------------------------------------------------
@Test
void copyToTarget_rejectsNullSourceLocator() throws IOException {
assertThatNullPointerException()
.isThrownBy(() -> adapter.copyToTarget(null, "2026-01-01 - Test.pdf"));
}
@Test
void copyToTarget_rejectsNullResolvedFilename() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
assertThatNullPointerException()
.isThrownBy(() -> adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
null));
}
@Test
void constructor_rejectsNullTargetFolderPath() {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFileCopyAdapter(null));
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Path createSourceFile(String filename, String content) throws IOException {
Path file = sourceFolder.resolve(filename);
Files.writeString(file, content);
return file;
}
}

View File

@@ -0,0 +1,259 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link FilesystemTargetFolderAdapter}.
* <p>
* Covers duplicate resolution (no conflict, single conflict, multiple conflicts),
* suffix placement, rollback deletion, and error handling.
*/
class FilesystemTargetFolderAdapterTest {
@TempDir
Path targetFolder;
private FilesystemTargetFolderAdapter adapter;
@BeforeEach
void setUp() {
adapter = new FilesystemTargetFolderAdapter(targetFolder);
}
// -------------------------------------------------------------------------
// getTargetFolderLocator
// -------------------------------------------------------------------------
@Test
void getTargetFolderLocator_returnsAbsolutePath() {
String locator = adapter.getTargetFolderLocator();
assertThat(locator).isEqualTo(targetFolder.toAbsolutePath().toString());
}
@Test
void getTargetFolderLocator_isNeverNullOrBlank() {
assertThat(adapter.getTargetFolderLocator()).isNotNull().isNotBlank();
}
// -------------------------------------------------------------------------
// resolveUniqueFilename no conflict
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_noConflict_returnsBaseName() {
String baseName = "2026-01-15 - Rechnung.pdf";
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename collision with base name
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameTaken_returnsSuffixOne() throws IOException {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-01-15 - Rechnung(1).pdf");
}
@Test
void resolveUniqueFilename_baseAndOneTaken_returnsSuffixTwo() throws IOException {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-01-15 - Rechnung(2).pdf");
}
@Test
void resolveUniqueFilename_multipleTaken_returnsFirstFree() throws IOException {
String baseName = "2026-03-31 - Stromabrechnung.pdf";
// Create base + (1), (2), (3)
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(1).pdf"));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(2).pdf"));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(3).pdf"));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-03-31 - Stromabrechnung(4).pdf");
}
// -------------------------------------------------------------------------
// Suffix placement: immediately before .pdf
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_suffixPlacedImmediatelyBeforePdf() throws IOException {
String baseName = "2026-04-07 - Bescheid.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
// Must end with "(1).pdf", not ".pdf(1)"
assertThat(resolved).endsWith("(1).pdf");
assertThat(resolved).doesNotContain(".pdf(");
}
// -------------------------------------------------------------------------
// Suffix does not count against 20-char base title
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_20CharTitle_suffixDoesNotViolateTitleLimit() throws IOException {
// Base title has exactly 20 chars; with (1) suffix the title exceeds 20, but that is expected
String title = "A".repeat(20); // 20-char title
String baseName = "2026-01-01 - " + title + ".pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
// The resolved filename must contain (1) even though overall length > 20 chars
assertThat(resolved).contains("(1)");
}
// -------------------------------------------------------------------------
// resolveUniqueFilename base name without .pdf extension
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameWithoutPdfExtension_whenConflict_returnsFailure()
throws IOException {
// When there is no conflict (file does not exist), the adapter returns the name as-is
// because it only checks the extension when it needs to insert a suffix.
String nameWithoutExt = "2026-01-15 - Rechnung";
// Create a file with that name (no extension) to trigger conflict handling
Files.createFile(targetFolder.resolve(nameWithoutExt));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
// Without .pdf extension, suffix insertion fails
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename no conflict, name without .pdf (edge: no conflict → ok)
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameWithoutPdfExtension_whenNoConflict_returnsIt() {
// If the name does not exist, the adapter returns it without checking the extension
String nameWithoutExt = "2026-01-15 - Rechnung";
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename null guard
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_rejectsNullBaseName() {
assertThatNullPointerException()
.isThrownBy(() -> adapter.resolveUniqueFilename(null));
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile file exists, gets deleted
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_fileExists_deletesFile() throws IOException {
String filename = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(filename));
assertThat(targetFolder.resolve(filename)).exists();
adapter.tryDeleteTargetFile(filename);
assertThat(targetFolder.resolve(filename)).doesNotExist();
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile file does not exist, no error
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() {
// Must not throw even if the file is absent
adapter.tryDeleteTargetFile("nonexistent.pdf");
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile null guard
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_rejectsNullFilename() {
assertThatNullPointerException()
.isThrownBy(() -> adapter.tryDeleteTargetFile(null));
}
// -------------------------------------------------------------------------
// resolveUniqueFilename non-existent target folder
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_nonExistentTargetFolder_returnsFailure() {
Path nonExistentFolder = targetFolder.resolve("does-not-exist");
FilesystemTargetFolderAdapter adapterWithMissingFolder =
new FilesystemTargetFolderAdapter(nonExistentFolder);
String baseName = "2026-01-01 - Test.pdf";
// Files.exists() on a file in a non-existent folder does not throw;
// it simply returns false, so the adapter returns the base name.
// This is consistent behaviour: no folder access error when just checking existence.
TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName);
// Adapter returns the base name since no conflict is detected for a non-existent folder
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
}
// -------------------------------------------------------------------------
// Construction null guard
// -------------------------------------------------------------------------
@Test
void constructor_rejectsNullTargetFolderPath() {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
}
}