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:
+33
@@ -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>
|
||||
|
||||
+33
@@ -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.
|
||||
*
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-8
@@ -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>
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
-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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+62
-10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user