V2.8: Selektive Wiederverarbeitung und Statusreset in der GUI

- Mehrfachauswahl mit CheckBox-Spalte und Master-Tri-State-Checkbox
- Gezielter Mini-Lauf über ausgewählte Einträge (unabhängig vom Status)
- Statusreset für ausgewählte Einträge (Stammsatz + Versuchshistorie)
- Fehlende Quelldatei im Mini-Lauf wird als FAILED_PERMANENT synthetisiert
- Identische Zieldatei wird als SUCCESS ohne erneute KI-Verarbeitung erkannt
- Weiche Stop-Semantik erhält zurückgesetzte Einträge unverändert
- Nicht-ausgewählte Einträge bleiben in allen Pfaden unberührt
- Buttons reagieren jetzt korrekt auf Auswahländerungen

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:04:22 +02:00
parent f4a1bce9ae
commit 9fd5bd5a52
40 changed files with 3478 additions and 223 deletions
@@ -296,6 +296,39 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
return stringValue != null && !stringValue.isBlank() ? Instant.parse(stringValue) : null;
}
/**
* Deletes the master record for the given fingerprint.
* <p>
* Idempotent: if no record with the given fingerprint exists the method returns
* without error. A {@link DocumentPersistenceException} is thrown only on technical
* database failures.
*
* @param fingerprint the document identity whose master record should be removed;
* must not be null
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
@Override
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = "DELETE FROM document_record WHERE fingerprint = ?";
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, fingerprint.sha256Hex());
int rowsAffected = statement.executeUpdate();
logger.debug("Deleted {} document_record row(s) for fingerprint: {}",
rowsAffected, fingerprint.sha256Hex());
} catch (SQLException e) {
String message = "Failed to delete document record for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* <p>
@@ -355,6 +355,39 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
return rs.wasNull() ? null : value;
}
/**
* Deletes all attempt history entries for the given fingerprint.
* <p>
* Idempotent: if no attempts exist for the fingerprint the method returns without
* error. A {@link DocumentPersistenceException} is thrown only on technical database
* failures.
*
* @param fingerprint the document identity whose attempt records should be removed;
* must not be null
* @throws DocumentPersistenceException if the delete fails due to a technical error
*/
@Override
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, fingerprint.sha256Hex());
int rowsAffected = statement.executeUpdate();
logger.debug("Deleted {} processing_attempt row(s) for fingerprint: {}",
rowsAffected, fingerprint.sha256Hex());
} catch (SQLException e) {
String message = "Failed to delete processing attempts for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns the JDBC URL this adapter uses.
*
@@ -15,6 +15,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceExcept
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* SQLite implementation of {@link UnitOfWorkPort}.
@@ -163,5 +164,39 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
};
repo.update(record);
}
/**
* Deletes all attempt history entries and the document master record for the
* given fingerprint within the current transaction.
* <p>
* Attempts are deleted first to satisfy the foreign-key constraint between
* {@code processing_attempt} and {@code document_record}. Both deletes are
* idempotent: missing rows are silently ignored.
*
* @param fingerprint the document identity to fully reset; must not be null
* @throws DocumentPersistenceException if either delete fails
*/
@Override
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
// Delete attempts first (FK constraint: processing_attempt → document_record)
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
@Override
protected Connection getConnection() throws SQLException {
return nonClosingWrapper(connection);
}
};
attemptRepo.deleteAllByFingerprint(fingerprint);
// Then delete the master record
SqliteDocumentRecordRepositoryAdapter recordRepo =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
@Override
protected Connection getConnection() throws SQLException {
return nonClosingWrapper(connection);
}
};
recordRepo.deleteByFingerprint(fingerprint);
}
}
}
@@ -1,17 +1,23 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
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 de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Filesystem-based implementation of {@link TargetFolderPort}.
@@ -67,27 +73,47 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
/**
* Resolves the first available unique filename in the target folder for the given base name.
* Resolves the first available unique filename in the target folder for the given base name,
* applying an identical-content shortcut when the base name already exists.
* <p>
* Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
* directly before {@code .pdf} until a free name is found.
* Processing order:
* <ol>
* <li>If the base name does not yet exist, return it unchanged as a
* {@link ResolvedTargetFilename}.</li>
* <li>If the base name exists and its SHA-256 matches {@code sourceFingerprint},
* return {@link ExistingIdenticalTargetFile} — the target is already up-to-date.</li>
* <li>Otherwise try {@code (1)}, {@code (2)}, etc. until a free name is found and
* return it as a {@link ResolvedTargetFilename}.</li>
* </ol>
*
* @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
* @param baseName the desired filename including {@code .pdf} extension;
* must not be null or blank
* @param sourceFingerprint the SHA-256 fingerprint of the source document; must not be null
* @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
* {@link TargetFolderTechnicalFailure} if folder access fails
*/
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
public TargetFilenameResolutionResult resolveUniqueFilename(
String baseName, DocumentFingerprint sourceFingerprint) {
Objects.requireNonNull(baseName, "baseName must not be null");
Objects.requireNonNull(sourceFingerprint, "sourceFingerprint must not be null");
try {
Path baseNamePath = targetFolderPath.resolve(baseName);
// Try without suffix first
if (!Files.exists(targetFolderPath.resolve(baseName))) {
if (!Files.exists(baseNamePath)) {
logger.debug("Resolved target filename without suffix: '{}'", baseName);
return new ResolvedTargetFilename(baseName);
}
// The base name exists — check for identical content before adding a suffix
if (isIdenticalContent(baseNamePath, sourceFingerprint)) {
logger.debug("Target file '{}' already exists with identical content — no new copy needed.",
baseName);
return new ExistingIdenticalTargetFile(baseName);
}
// Determine split point: everything before the final ".pdf"
if (!baseName.toLowerCase().endsWith(".pdf")) {
return new TargetFolderTechnicalFailure(
@@ -115,6 +141,36 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
}
}
/**
* Returns {@code true} when the SHA-256 digest of the file at {@code targetPath} matches
* the hex value in {@code sourceFingerprint}.
* <p>
* Any I/O or digest error is treated as non-identical (returns {@code false} and logs
* at debug level), so the duplicate-suffix path is entered instead.
*
* @param targetPath path of the existing target file to compare
* @param sourceFingerprint expected SHA-256 hex digest of the source document
* @return {@code true} if the existing file's SHA-256 equals the source fingerprint
*/
private boolean isIdenticalContent(Path targetPath, DocumentFingerprint sourceFingerprint) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = Files.newInputStream(targetPath)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
String targetHex = HexFormat.of().formatHex(digest.digest());
return targetHex.equalsIgnoreCase(sourceFingerprint.sha256Hex());
} catch (NoSuchAlgorithmException | IOException e) {
logger.debug("Could not compute SHA-256 of existing target file '{}': {} — "
+ "treating as non-identical.", targetPath.getFileName(), e.getMessage());
return false;
}
}
/**
* Best-effort deletion of a file in the target folder.
* <p>
@@ -661,4 +661,47 @@ class SqliteDocumentRecordRepositoryAdapterTest {
assertThat(success.record().createdAt()).isEqualTo(createdAt);
assertThat(success.record().updatedAt()).isEqualTo(now);
}
// -------------------------------------------------------------------------
// deleteByFingerprint
// -------------------------------------------------------------------------
@Test
void deleteByFingerprint_removesExistingRecord() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
DocumentRecord record = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/path/del.pdf"),
"del.pdf",
ProcessingStatus.PROCESSING,
FailureCounters.zero(),
null,
null,
Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
);
repository.create(record);
assertThat(repository.findByFingerprint(fingerprint))
.isNotInstanceOf(DocumentUnknown.class);
repository.deleteByFingerprint(fingerprint);
assertThat(repository.findByFingerprint(fingerprint))
.isInstanceOf(DocumentUnknown.class);
}
@Test
void deleteByFingerprint_isIdempotentWhenRecordAbsent() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
// Must not throw even if the record does not exist
repository.deleteByFingerprint(fingerprint);
assertThat(repository.findByFingerprint(fingerprint))
.isInstanceOf(DocumentUnknown.class);
}
}
@@ -883,4 +883,66 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
throw new RuntimeException("Failed to insert document record for testing", e);
}
}
// -------------------------------------------------------------------------
// deleteAllByFingerprint
// -------------------------------------------------------------------------
@Test
void deleteAllByFingerprint_removesAllAttemptsForFingerprint() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
RunId runId = new RunId("test-run-delete");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
// Save two attempts
for (int i = 1; i <= 2; i++) {
repository.save(ProcessingAttempt.withoutAiFields(
fingerprint, runId, i, now, now.plusSeconds(i),
ProcessingStatus.FAILED_RETRYABLE, "SomeError", "message", true));
}
assertThat(repository.findAllByFingerprint(fingerprint)).hasSize(2);
repository.deleteAllByFingerprint(fingerprint);
assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
}
@Test
void deleteAllByFingerprint_isIdempotentWhenNoAttemptsExist() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
// Must not throw even if no attempts exist for this fingerprint
repository.deleteAllByFingerprint(fingerprint);
assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
}
@Test
void deleteAllByFingerprint_doesNotAffectOtherFingerprints() {
DocumentFingerprint fp1 = new DocumentFingerprint(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
DocumentFingerprint fp2 = new DocumentFingerprint(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
RunId runId = new RunId("run-isolation");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fp1);
insertDocumentRecord(fp2);
repository.save(ProcessingAttempt.withoutAiFields(
fp1, runId, 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
repository.save(ProcessingAttempt.withoutAiFields(
fp2, runId, 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
repository.deleteAllByFingerprint(fp1);
assertThat(repository.findAllByFingerprint(fp1)).isEmpty();
assertThat(repository.findAllByFingerprint(fp2)).hasSize(1);
}
}
@@ -1,5 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -15,9 +16,12 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
@@ -227,8 +231,68 @@ class SqliteUnitOfWorkAdapterTest {
unitOfWorkAdapter.executeInTransaction(txOps -> txOps.createDocumentRecord(record));
var result = docRepository.findByFingerprint(fingerprint);
assertFalse(result instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
assertFalse(result instanceof DocumentUnknown,
"Record must be persisted and retrievable after a successfully committed transaction");
}
// -------------------------------------------------------------------------
// resetDocumentByFingerprint
// -------------------------------------------------------------------------
@Test
void resetDocumentByFingerprint_deletesMasterRecordAndAttempts() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
DocumentRecord record = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/reset-test.pdf"),
"reset-test.pdf",
ProcessingStatus.PROCESSING,
FailureCounters.zero(),
null,
null,
now,
now,
null,
null
);
SqliteDocumentRecordRepositoryAdapter docRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
SqliteProcessingAttemptRepositoryAdapter attemptRepository =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
// Persist master record and one attempt
unitOfWorkAdapter.executeInTransaction(txOps -> {
txOps.createDocumentRecord(record);
txOps.saveProcessingAttempt(ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-reset"), 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
});
assertThat(docRepository.findByFingerprint(fingerprint)).isNotInstanceOf(DocumentUnknown.class);
assertThat(attemptRepository.findAllByFingerprint(fingerprint)).hasSize(1);
// Reset
unitOfWorkAdapter.executeInTransaction(txOps ->
txOps.resetDocumentByFingerprint(fingerprint));
assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
assertThat(attemptRepository.findAllByFingerprint(fingerprint)).isEmpty();
}
@Test
void resetDocumentByFingerprint_isIdempotentWhenRecordAbsent() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
// Must not throw even if no record exists
unitOfWorkAdapter.executeInTransaction(txOps ->
txOps.resetDocumentByFingerprint(fingerprint));
SqliteDocumentRecordRepositoryAdapter docRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
}
}
@@ -6,14 +6,18 @@ import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
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 de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
/**
* Tests for {@link FilesystemTargetFolderAdapter}.
@@ -23,6 +27,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFail
*/
class FilesystemTargetFolderAdapterTest {
/** A fingerprint whose hex value differs from any real file content in tests. */
private static final DocumentFingerprint DUMMY_FP =
new DocumentFingerprint("0".repeat(64));
@TempDir
Path targetFolder;
@@ -57,7 +65,7 @@ class FilesystemTargetFolderAdapterTest {
void resolveUniqueFilename_noConflict_returnsBaseName() {
String baseName = "2026-01-15 - Rechnung.pdf";
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
@@ -72,7 +80,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -85,7 +93,7 @@ class FilesystemTargetFolderAdapterTest {
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -101,7 +109,7 @@ class FilesystemTargetFolderAdapterTest {
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);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
@@ -117,7 +125,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-04-07 - Bescheid.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
@@ -137,7 +145,7 @@ class FilesystemTargetFolderAdapterTest {
String baseName = "2026-01-01 - " + title + ".pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
@@ -159,7 +167,7 @@ class FilesystemTargetFolderAdapterTest {
// Create a file with that name (no extension) to trigger conflict handling
Files.createFile(targetFolder.resolve(nameWithoutExt));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
// Without .pdf extension, suffix insertion fails
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
@@ -174,7 +182,7 @@ class FilesystemTargetFolderAdapterTest {
// 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);
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
@@ -187,7 +195,7 @@ class FilesystemTargetFolderAdapterTest {
@Test
void resolveUniqueFilename_rejectsNullBaseName() {
assertThatNullPointerException()
.isThrownBy(() -> adapter.resolveUniqueFilename(null));
.isThrownBy(() -> adapter.resolveUniqueFilename(null, DUMMY_FP));
}
// -------------------------------------------------------------------------
@@ -240,7 +248,7 @@ class FilesystemTargetFolderAdapterTest {
// 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);
TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName, DUMMY_FP);
// Adapter returns the base name since no conflict is detected for a non-existent folder
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
@@ -256,4 +264,48 @@ class FilesystemTargetFolderAdapterTest {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
}
// -------------------------------------------------------------------------
// resolveUniqueFilename identical-content shortcut
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_existingFileWithIdenticalContent_returnsExistingIdenticalTargetFile()
throws Exception {
// Arrange: write a file with known content and compute its SHA-256
String baseName = "2026-01-15 - Identisch.pdf";
byte[] content = "identical content for test".getBytes();
Files.write(targetFolder.resolve(baseName), content);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(content);
String sha256Hex = HexFormat.of().formatHex(digest.digest());
DocumentFingerprint matchingFp = new DocumentFingerprint(sha256Hex);
// Act
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, matchingFp);
// Assert: shortcut path returns ExistingIdenticalTargetFile, not a new suffix
assertThat(result).isInstanceOf(ExistingIdenticalTargetFile.class);
assertThat(((ExistingIdenticalTargetFile) result).existingFilename()).isEqualTo(baseName);
}
@Test
void resolveUniqueFilename_existingFileWithDifferentContent_returnsSuffixedFilename()
throws IOException {
// Arrange: existing file with some content; source fingerprint differs
String baseName = "2026-01-15 - Verschieden.pdf";
Files.write(targetFolder.resolve(baseName), "existing content".getBytes());
// Use a fingerprint whose hex does not match the SHA-256 of the existing file
DocumentFingerprint differentFp = new DocumentFingerprint("0".repeat(64));
// Act
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, differentFp);
// Assert: different content → suffix appended
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-01-15 - Verschieden(1).pdf");
}
}