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:
+9
-2
@@ -4,6 +4,8 @@ import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Immutable event describing the outcome of processing exactly one candidate document.
|
||||
* <p>
|
||||
@@ -16,6 +18,8 @@ import java.util.Objects;
|
||||
*
|
||||
* @param originalFileName the source candidate's unique identifier (typically the source
|
||||
* filename); never {@code null} or blank
|
||||
* @param fingerprint the content-based identity of the processed document;
|
||||
* never {@code null}
|
||||
* @param status the aggregated outcome status; never {@code null}
|
||||
* @param finalFileName the final target filename, including any duplicate suffix;
|
||||
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
|
||||
@@ -32,6 +36,7 @@ import java.util.Objects;
|
||||
*/
|
||||
public record DocumentCompletionEvent(
|
||||
String originalFileName,
|
||||
DocumentFingerprint fingerprint,
|
||||
DocumentCompletionStatus status,
|
||||
String finalFileName,
|
||||
LocalDate resolvedDate,
|
||||
@@ -41,8 +46,9 @@ public record DocumentCompletionEvent(
|
||||
/**
|
||||
* Compact constructor validating mandatory fields.
|
||||
*
|
||||
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
||||
* {@code processingDuration} is {@code null}
|
||||
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
|
||||
* {@code status} or {@code processingDuration} is
|
||||
* {@code null}
|
||||
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||
* {@code processingDuration} is negative
|
||||
*/
|
||||
@@ -51,6 +57,7 @@ public record DocumentCompletionEvent(
|
||||
if (originalFileName.isBlank()) {
|
||||
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||
}
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||
if (processingDuration.isNegative()) {
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Immutable summary of a {@link ResetDocumentStatusUseCase#reset(Set)} invocation.
|
||||
* <p>
|
||||
* Reports how many documents were requested for reset, which were successfully reset,
|
||||
* and which encountered a technical failure. Callers can use this record to present
|
||||
* a user-visible result or decide on follow-up actions.
|
||||
*
|
||||
* @param requestedCount total number of fingerprints that were passed to the reset
|
||||
* operation; always >= 0
|
||||
* @param successfullyReset set of fingerprints that were successfully deleted from
|
||||
* persistence; never null
|
||||
* @param failures map of fingerprint → error message for every fingerprint
|
||||
* whose reset operation encountered a technical failure;
|
||||
* never null
|
||||
*/
|
||||
public record ResetDocumentStatusResult(
|
||||
int requestedCount,
|
||||
Set<DocumentFingerprint> successfullyReset,
|
||||
Map<DocumentFingerprint, String> failures) {
|
||||
|
||||
/**
|
||||
* Compact constructor validating and defensively copying the mutable collections.
|
||||
*
|
||||
* @throws NullPointerException if {@code successfullyReset} or {@code failures}
|
||||
* is null
|
||||
* @throws IllegalArgumentException if {@code requestedCount} is negative
|
||||
*/
|
||||
public ResetDocumentStatusResult {
|
||||
if (requestedCount < 0) {
|
||||
throw new IllegalArgumentException("requestedCount must not be negative");
|
||||
}
|
||||
Objects.requireNonNull(successfullyReset, "successfullyReset must not be null");
|
||||
Objects.requireNonNull(failures, "failures must not be null");
|
||||
successfullyReset = Collections.unmodifiableSet(Set.copyOf(successfullyReset));
|
||||
failures = Collections.unmodifiableMap(Map.copyOf(failures));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of fingerprints that were successfully reset.
|
||||
*
|
||||
* @return the count of successfully reset documents; always >= 0
|
||||
*/
|
||||
public int successCount() {
|
||||
return successfullyReset.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of fingerprints for which the reset failed with a technical error.
|
||||
*
|
||||
* @return the count of failed resets; always >= 0
|
||||
*/
|
||||
public int failureCount() {
|
||||
return failures.size();
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Inbound port for resetting the processing status of one or more documents.
|
||||
* <p>
|
||||
* A reset removes all persistence data (attempt history and document master record)
|
||||
* for the specified fingerprints, making those documents eligible for reprocessing in
|
||||
* the next regular or targeted batch run as if they had never been processed.
|
||||
* <p>
|
||||
* The operation follows a best-effort semantics: each fingerprint is processed
|
||||
* independently. A technical failure for one fingerprint does not prevent the reset
|
||||
* from being attempted for the remaining fingerprints. The result carries the full
|
||||
* accounting of successes and failures.
|
||||
*/
|
||||
public interface ResetDocumentStatusUseCase {
|
||||
|
||||
/**
|
||||
* Resets the processing status for the supplied set of document fingerprints.
|
||||
* <p>
|
||||
* For each fingerprint the implementation deletes the document master record and
|
||||
* all associated attempt history within a single atomic transaction. If the
|
||||
* transaction fails for a given fingerprint, that fingerprint's error is recorded
|
||||
* in the result's {@link ResetDocumentStatusResult#failures() failures} map and
|
||||
* processing continues with the remaining fingerprints.
|
||||
*
|
||||
* @param fingerprints the set of document fingerprints to reset; must not be null;
|
||||
* may be empty (results in a completed result with zero requests)
|
||||
* @return a {@link ResetDocumentStatusResult} describing the outcome; never null
|
||||
*/
|
||||
ResetDocumentStatusResult reset(Set<DocumentFingerprint> fingerprints);
|
||||
}
|
||||
+13
@@ -68,4 +68,17 @@ public interface DocumentRecordRepository {
|
||||
* @throws DocumentPersistenceException if the update fails due to a technical error
|
||||
*/
|
||||
void update(DocumentRecord record);
|
||||
|
||||
/**
|
||||
* Deletes the master record for the given fingerprint.
|
||||
* <p>
|
||||
* This operation is idempotent: if no record exists for the fingerprint, the method
|
||||
* returns without error. A {@link DocumentPersistenceException} is thrown only on
|
||||
* technical failures such as database connectivity errors.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
void deleteByFingerprint(DocumentFingerprint fingerprint);
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outcome of {@link TargetFolderPort#resolveUniqueFilename(String)} when the target file
|
||||
* at the proposed base name already exists <em>and</em> its binary content is identical
|
||||
* to the source document (same SHA-256 fingerprint).
|
||||
* <p>
|
||||
* This result signals to the application layer that no new copy is needed: the existing
|
||||
* target file is byte-for-byte identical to the source. The processing coordinator treats
|
||||
* this as a successful outcome — the document is considered already present in the target
|
||||
* folder under the given filename.
|
||||
*
|
||||
* @param existingFilename the filename of the already-existing identical target file,
|
||||
* including extension; never null or blank
|
||||
*/
|
||||
public record ExistingIdenticalTargetFile(String existingFilename)
|
||||
implements TargetFilenameResolutionResult {
|
||||
|
||||
/**
|
||||
* Compact constructor validating the filename.
|
||||
*
|
||||
* @throws NullPointerException if {@code existingFilename} is null
|
||||
* @throws IllegalArgumentException if {@code existingFilename} is blank
|
||||
*/
|
||||
public ExistingIdenticalTargetFile {
|
||||
Objects.requireNonNull(existingFilename, "existingFilename must not be null");
|
||||
if (existingFilename.isBlank()) {
|
||||
throw new IllegalArgumentException("existingFilename must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -88,4 +88,17 @@ public interface ProcessingAttemptRepository {
|
||||
* @throws DocumentPersistenceException if the query fails due to a technical error
|
||||
*/
|
||||
ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint);
|
||||
|
||||
/**
|
||||
* Deletes all attempt history entries for the given fingerprint.
|
||||
* <p>
|
||||
* This operation is idempotent: if no attempts exist for the fingerprint, the method
|
||||
* returns without error. A {@link DocumentPersistenceException} is thrown only on
|
||||
* technical failures such as database connectivity errors.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
void deleteAllByFingerprint(DocumentFingerprint fingerprint);
|
||||
}
|
||||
|
||||
+5
-2
@@ -3,12 +3,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
/**
|
||||
* Sealed result type for {@link TargetFolderPort#resolveUniqueFilename(String)}.
|
||||
* <p>
|
||||
* Permits exactly two outcomes:
|
||||
* Permits exactly three outcomes:
|
||||
* <ul>
|
||||
* <li>{@link ResolvedTargetFilename} — the first available unique filename was determined.</li>
|
||||
* <li>{@link ExistingIdenticalTargetFile} — the base name already exists in the target folder
|
||||
* and the existing file is byte-for-byte identical to the source document; no new copy
|
||||
* is needed.</li>
|
||||
* <li>{@link TargetFolderTechnicalFailure} — the target folder could not be accessed.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public sealed interface TargetFilenameResolutionResult
|
||||
permits ResolvedTargetFilename, TargetFolderTechnicalFailure {
|
||||
permits ResolvedTargetFilename, ExistingIdenticalTargetFile, TargetFolderTechnicalFailure {
|
||||
}
|
||||
|
||||
+30
-9
@@ -1,5 +1,7 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Outbound port for target folder access: duplicate resolution and best-effort cleanup.
|
||||
* <p>
|
||||
@@ -21,6 +23,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
* purely a technical collision-avoidance mechanism and introduces no new fachliche
|
||||
* title interpretation.
|
||||
*
|
||||
* <h2>Identical-content shortcut</h2>
|
||||
* <p>
|
||||
* Before appending any numeric suffix, the implementation checks whether the base name
|
||||
* already exists in the target folder <em>and</em> whether that existing file is
|
||||
* byte-for-byte identical to the source document (verified via the supplied
|
||||
* {@link DocumentFingerprint}). When both conditions hold, the method returns
|
||||
* {@link ExistingIdenticalTargetFile} instead of {@link ResolvedTargetFilename},
|
||||
* signalling that no new copy is required.
|
||||
*
|
||||
* <h2>Architecture boundary</h2>
|
||||
* <p>
|
||||
* No {@code Path}, {@code File}, or NIO types appear in this interface. The concrete
|
||||
@@ -41,22 +52,32 @@ public interface TargetFolderPort {
|
||||
String getTargetFolderLocator();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* taking the source document's fingerprint into account for identity-based shortcutting.
|
||||
* <p>
|
||||
* If the base name is not yet taken, it is returned unchanged. Otherwise the method
|
||||
* 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 in the target folder, return
|
||||
* {@link ResolvedTargetFilename} with the base name.</li>
|
||||
* <li>If the base name exists and its content is identical to the source document
|
||||
* (SHA-256 comparison using {@code sourceFingerprint}), return
|
||||
* {@link ExistingIdenticalTargetFile} — no new copy is needed.</li>
|
||||
* <li>Otherwise append {@code (1)}, {@code (2)}, etc. directly before {@code .pdf}
|
||||
* until a free name is found; return {@link ResolvedTargetFilename} with that name.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* The returned filename contains only the file name, not the full path. It is safe
|
||||
* to use as the {@code resolvedFilename} parameter of
|
||||
* {@link TargetFileCopyPort#copyToTarget(de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator, String)}.
|
||||
*
|
||||
* @param baseName the desired filename including the {@code .pdf} extension;
|
||||
* must not be null or blank
|
||||
* @return a {@link ResolvedTargetFilename} with the first available name, or a
|
||||
* {@link TargetFolderTechnicalFailure} if the target folder is not accessible
|
||||
* @param baseName the desired filename including the {@code .pdf} extension;
|
||||
* must not be null or blank
|
||||
* @param sourceFingerprint the SHA-256 fingerprint of the source document used for
|
||||
* identical-content detection; must not be null
|
||||
* @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
|
||||
* {@link TargetFolderTechnicalFailure}
|
||||
*/
|
||||
TargetFilenameResolutionResult resolveUniqueFilename(String baseName);
|
||||
TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint);
|
||||
|
||||
/**
|
||||
* Best-effort attempt to delete a file previously written to the target folder.
|
||||
|
||||
+35
-3
@@ -2,15 +2,16 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Port for executing multiple repository operations within a single unit of work.
|
||||
* <p>
|
||||
* Ensures that related persistence operations (such as saving a processing attempt
|
||||
* and updating a document record) are executed atomically.
|
||||
*
|
||||
*/
|
||||
public interface UnitOfWorkPort {
|
||||
|
||||
|
||||
/**
|
||||
* Executes the given operations within a single unit of work.
|
||||
* <p>
|
||||
@@ -20,13 +21,44 @@ public interface UnitOfWorkPort {
|
||||
* @throws DocumentPersistenceException if any operation fails
|
||||
*/
|
||||
void executeInTransaction(Consumer<TransactionOperations> operations);
|
||||
|
||||
|
||||
/**
|
||||
* Operations available within a transaction.
|
||||
*/
|
||||
interface TransactionOperations {
|
||||
|
||||
/**
|
||||
* Saves a processing attempt within the current transaction.
|
||||
*
|
||||
* @param attempt the attempt to persist; must not be null
|
||||
*/
|
||||
void saveProcessingAttempt(ProcessingAttempt attempt);
|
||||
|
||||
/**
|
||||
* Creates a new document master record within the current transaction.
|
||||
*
|
||||
* @param record the new record to persist; must not be null
|
||||
*/
|
||||
void createDocumentRecord(DocumentRecord record);
|
||||
|
||||
/**
|
||||
* Updates an existing document master record within the current transaction.
|
||||
*
|
||||
* @param record the updated record; must not be null; fingerprint must exist
|
||||
*/
|
||||
void updateDocumentRecord(DocumentRecord record);
|
||||
|
||||
/**
|
||||
* Deletes all attempt history entries and the document master record for the
|
||||
* given fingerprint within the current transaction.
|
||||
* <p>
|
||||
* Deletion order must respect foreign-key constraints: attempt history rows are
|
||||
* removed first, then the master record. This operation is idempotent — if no
|
||||
* data exists for the fingerprint the method returns silently.
|
||||
*
|
||||
* @param fingerprint the document identity to fully reset; must not be null
|
||||
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||
*/
|
||||
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
||||
}
|
||||
}
|
||||
+28
-8
@@ -17,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
@@ -164,8 +165,8 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
/**
|
||||
* Optional per-run completion forwarder that is consulted by
|
||||
* {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
|
||||
* LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
||||
* {@link #publishCompletion(SourceDocumentCandidate, DocumentFingerprint, DocumentCompletionStatus,
|
||||
* String, LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
||||
* <p>
|
||||
* Assigned by the inbound use case for the duration of a single run and cleared before the
|
||||
* use case returns. A {@code null} value means no external observer is attached and the
|
||||
@@ -490,8 +491,10 @@ public class DocumentProcessingCoordinator {
|
||||
String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();
|
||||
|
||||
// --- Step 3: Resolve unique filename in target folder ---
|
||||
// Passing the source fingerprint enables the adapter to detect an identical existing
|
||||
// target file and return ExistingIdenticalTargetFile instead of a numbered suffix.
|
||||
TargetFilenameResolutionResult resolutionResult =
|
||||
targetFolderPort.resolveUniqueFilename(baseFilename);
|
||||
targetFolderPort.resolveUniqueFilename(baseFilename, fingerprint);
|
||||
|
||||
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
|
||||
logger.error("Duplicate resolution failed for '{}': {}",
|
||||
@@ -501,6 +504,20 @@ public class DocumentProcessingCoordinator {
|
||||
"Target folder duplicate resolution failed: " + folderFailure.errorMessage());
|
||||
}
|
||||
|
||||
// Identical-content shortcut: target already exists with the same content — treat as
|
||||
// SUCCESS without writing a new copy.
|
||||
if (resolutionResult instanceof ExistingIdenticalTargetFile identicalFile) {
|
||||
logger.info("Target file '{}' already exists with identical content for '{}' "
|
||||
+ "(fingerprint: {}). Treating as success without new copy.",
|
||||
identicalFile.existingFilename(), candidate.uniqueIdentifier(),
|
||||
fingerprint.sha256Hex());
|
||||
return persistTargetCopySuccess(
|
||||
candidate, fingerprint, existingRecord, context, attemptStart, now,
|
||||
identicalFile.existingFilename(),
|
||||
targetFolderPort.getTargetFolderLocator(),
|
||||
proposalAttempt);
|
||||
}
|
||||
|
||||
String resolvedFilename =
|
||||
((ResolvedTargetFilename) resolutionResult).resolvedFilename();
|
||||
logger.info("Generated target filename for '{}' (fingerprint: {}): '{}'.",
|
||||
@@ -597,7 +614,7 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
logger.info("Document '{}' successfully processed. Target: '{}'.",
|
||||
candidate.uniqueIdentifier(), resolvedFilename);
|
||||
publishCompletion(candidate, DocumentCompletionStatus.SUCCESS,
|
||||
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SUCCESS,
|
||||
resolvedFilename,
|
||||
proposalAttempt.resolvedDate(),
|
||||
proposalAttempt.aiReasoning(),
|
||||
@@ -681,7 +698,7 @@ public class DocumentProcessingCoordinator {
|
||||
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
||||
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
||||
}
|
||||
publishCompletion(candidate,
|
||||
publishCompletion(candidate, fingerprint,
|
||||
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
null, null, null, attemptStart, now);
|
||||
@@ -750,7 +767,7 @@ public class DocumentProcessingCoordinator {
|
||||
// completion event keeps the observer in sync with the user-visible state even though
|
||||
// nothing new was persisted.
|
||||
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
|
||||
publishCompletion(candidate,
|
||||
publishCompletion(candidate, fingerprint,
|
||||
transition.retryable()
|
||||
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||
@@ -797,7 +814,7 @@ public class DocumentProcessingCoordinator {
|
||||
|
||||
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
|
||||
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
|
||||
publishCompletion(candidate, DocumentCompletionStatus.SKIPPED,
|
||||
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED,
|
||||
null, null, null, attemptStart, now);
|
||||
return true;
|
||||
|
||||
@@ -1067,7 +1084,7 @@ public class DocumentProcessingCoordinator {
|
||||
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
|
||||
// the actual completion event (SUCCESS or transient-error failure).
|
||||
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
|
||||
publishCompletion(candidate, toCompletionStatus(outcome),
|
||||
publishCompletion(candidate, fingerprint, toCompletionStatus(outcome),
|
||||
null, null, null, attemptStart, now);
|
||||
}
|
||||
return true;
|
||||
@@ -1200,6 +1217,7 @@ public class DocumentProcessingCoordinator {
|
||||
* not affect persistence or batch flow.
|
||||
*
|
||||
* @param candidate the candidate being reported; must not be null
|
||||
* @param fingerprint the content-based identity of the document; must not be null
|
||||
* @param status the aggregated completion status; must not be null
|
||||
* @param finalFileName the final target filename on success; {@code null} otherwise
|
||||
* @param resolvedDate the resolved date on success; may be {@code null} otherwise
|
||||
@@ -1210,6 +1228,7 @@ public class DocumentProcessingCoordinator {
|
||||
*/
|
||||
private void publishCompletion(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentFingerprint fingerprint,
|
||||
DocumentCompletionStatus status,
|
||||
String finalFileName,
|
||||
LocalDate resolvedDate,
|
||||
@@ -1227,6 +1246,7 @@ public class DocumentProcessingCoordinator {
|
||||
try {
|
||||
forwarder.accept(new DocumentCompletionEvent(
|
||||
candidate.uniqueIdentifier(),
|
||||
fingerprint,
|
||||
status,
|
||||
finalFileName,
|
||||
resolvedDate,
|
||||
|
||||
+83
-11
@@ -1,8 +1,11 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||
@@ -16,6 +19,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||
@@ -42,6 +46,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||
* <li>For each candidate, execute the processing order:
|
||||
* <ol type="a">
|
||||
* <li>Compute fingerprint.</li>
|
||||
* <li>If a fingerprint filter is active in the {@link BatchRunContext}, skip
|
||||
* candidates whose fingerprint is not in the filter (no event, no persistence).</li>
|
||||
* <li>Load document master record.</li>
|
||||
* <li>If already {@code SUCCESS} → persist skip attempt with
|
||||
* {@code SKIPPED_ALREADY_PROCESSED}.</li>
|
||||
@@ -56,6 +62,18 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||
* <li>Release lock and return structured outcome for Bootstrap exit code mapping.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Fingerprint filter (mini-run)</h2>
|
||||
* <p>
|
||||
* When the {@link BatchRunContext} carries a fingerprint filter, the run restricts
|
||||
* processing to exactly those candidates whose SHA-256 fingerprint is contained in
|
||||
* the filter. Candidates not in the filter are silently skipped — no completion event
|
||||
* is emitted, no persistence record is written, and they do not count toward the
|
||||
* progress total reported to the {@link BatchRunProgressObserver}.
|
||||
* <p>
|
||||
* To provide the correct total count for the progress bar, fingerprints of all source
|
||||
* candidates are computed up front before the observer is notified of the run start.
|
||||
* Only filter-matching candidates are included in the total and the processing loop.
|
||||
*
|
||||
* <h2>Idempotency</h2>
|
||||
* <p>
|
||||
* Documents are identified exclusively by their SHA-256 content fingerprint. A document
|
||||
@@ -73,7 +91,6 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||
* For every identified document, the processing attempt and the master record are
|
||||
* written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
|
||||
* document are caught and logged; the batch run continues with the remaining candidates.
|
||||
*
|
||||
*/
|
||||
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {
|
||||
|
||||
@@ -206,7 +223,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads candidates and processes them one by one.
|
||||
* Loads candidates and processes them one by one, respecting any fingerprint filter
|
||||
* present on the {@link BatchRunContext}.
|
||||
* <p>
|
||||
* Document-level failures — including content errors, transient technical errors,
|
||||
* and individual persistence failures — do not affect the batch outcome. The batch
|
||||
@@ -217,26 +235,43 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
* <p>
|
||||
* Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
|
||||
* the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
|
||||
* <p>
|
||||
* When a fingerprint filter is active, all source-folder candidates are scanned but their
|
||||
* fingerprints are computed up front to determine which candidates belong to the effective
|
||||
* candidate list. Only filter-matching candidates count toward the total reported to the
|
||||
* observer and are included in the processing loop.
|
||||
*
|
||||
* @param context the current batch run context
|
||||
* @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
|
||||
* or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
|
||||
*/
|
||||
private BatchRunOutcome processCandidates(BatchRunContext context) {
|
||||
List<SourceDocumentCandidate> candidates;
|
||||
List<SourceDocumentCandidate> allCandidates;
|
||||
try {
|
||||
candidates = sourceDocumentCandidatesPort.loadCandidates();
|
||||
allCandidates = sourceDocumentCandidatesPort.loadCandidates();
|
||||
} catch (SourceDocumentAccessException e) {
|
||||
logger.error("Cannot access source folder: {}", e.getMessage(), e);
|
||||
return BatchRunOutcome.FAILURE;
|
||||
}
|
||||
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
||||
logger.info("Found {} PDF candidate(s) in source folder.", allCandidates.size());
|
||||
|
||||
// Notify observer of the known candidate count up-front so observers can size their
|
||||
// progress bars. The count reflects the source folder at scan time and remains fixed
|
||||
// for the remainder of the run (also when the run is cancelled early).
|
||||
// When a fingerprint filter is active, pre-compute fingerprints to determine
|
||||
// the effective candidate list and the correct total for the progress observer.
|
||||
Optional<Set<DocumentFingerprint>> filter = context.fingerprintFilter();
|
||||
List<SourceDocumentCandidate> effectiveCandidates;
|
||||
if (filter.isPresent()) {
|
||||
effectiveCandidates = buildFilteredCandidateList(allCandidates, filter.get(), context);
|
||||
logger.info("Fingerprint filter active: {} of {} candidate(s) match.",
|
||||
effectiveCandidates.size(), allCandidates.size());
|
||||
} else {
|
||||
effectiveCandidates = allCandidates;
|
||||
}
|
||||
|
||||
// Notify observer of the effective candidate count up-front so observers can size
|
||||
// their progress bars. The count reflects the filter-matched candidates and remains
|
||||
// fixed for the remainder of the run (also when the run is cancelled early).
|
||||
try {
|
||||
progressObserver.onRunStarted(context.runId(), candidates.size());
|
||||
progressObserver.onRunStarted(context.runId(), effectiveCandidates.size());
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
|
||||
}
|
||||
@@ -249,12 +284,12 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
try {
|
||||
int processedCount = 0;
|
||||
boolean cancelled = false;
|
||||
for (SourceDocumentCandidate candidate : candidates) {
|
||||
for (SourceDocumentCandidate candidate : effectiveCandidates) {
|
||||
if (cancellationTokenRequested()) {
|
||||
cancelled = true;
|
||||
logger.info("Cancellation requested before processing next candidate. "
|
||||
+ "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
|
||||
context.runId(), processedCount, candidates.size());
|
||||
context.runId(), processedCount, effectiveCandidates.size());
|
||||
break;
|
||||
}
|
||||
processCandidate(candidate, context);
|
||||
@@ -276,6 +311,43 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
||||
return BatchRunOutcome.SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computes fingerprints for all raw candidates and returns the subset whose
|
||||
* fingerprint is contained in the given filter set.
|
||||
* <p>
|
||||
* Candidates for which fingerprint computation fails are logged at warn level and
|
||||
* excluded from the effective list (consistent with the regular per-candidate
|
||||
* fingerprint-error handling).
|
||||
*
|
||||
* @param allCandidates all candidates from the source folder scan
|
||||
* @param filter the set of fingerprints to match against
|
||||
* @param context the current batch run context (used for logging)
|
||||
* @return the ordered sub-list of candidates whose fingerprints are in the filter
|
||||
*/
|
||||
private List<SourceDocumentCandidate> buildFilteredCandidateList(
|
||||
List<SourceDocumentCandidate> allCandidates,
|
||||
Set<DocumentFingerprint> filter,
|
||||
BatchRunContext context) {
|
||||
|
||||
List<SourceDocumentCandidate> matched = new ArrayList<>();
|
||||
for (SourceDocumentCandidate candidate : allCandidates) {
|
||||
FingerprintResult result = fingerprintPort.computeFingerprint(candidate);
|
||||
switch (result) {
|
||||
case FingerprintTechnicalError error -> {
|
||||
logger.warn("Fingerprint computation failed for '{}' during filter pre-pass "
|
||||
+ "(RunId: {}): {} — candidate excluded.",
|
||||
candidate.uniqueIdentifier(), context.runId(), error.errorMessage());
|
||||
}
|
||||
case FingerprintSuccess success -> {
|
||||
if (filter.contains(success.fingerprint())) {
|
||||
matched.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
private boolean cancellationTokenRequested() {
|
||||
try {
|
||||
return cancellationToken.isCancellationRequested();
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusUseCase;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link ResetDocumentStatusUseCase}.
|
||||
* <p>
|
||||
* For each requested fingerprint, this implementation deletes the document master
|
||||
* record and all associated attempt history in a single atomic transaction via
|
||||
* {@link UnitOfWorkPort}. Deletion order honours the foreign-key constraint:
|
||||
* attempt rows are removed before the master record.
|
||||
* <p>
|
||||
* The operation applies best-effort semantics: every fingerprint is attempted
|
||||
* independently. A technical failure for one fingerprint is caught, logged, and
|
||||
* recorded in the result's failure map; the remaining fingerprints continue to be
|
||||
* processed. The batch never aborts early.
|
||||
*/
|
||||
public class DefaultResetDocumentStatusUseCase implements ResetDocumentStatusUseCase {
|
||||
|
||||
private final UnitOfWorkPort unitOfWorkPort;
|
||||
private final ProcessingLogger logger;
|
||||
|
||||
/**
|
||||
* Creates the use case with the required persistence port and logger.
|
||||
*
|
||||
* @param unitOfWorkPort port for executing the delete operations atomically;
|
||||
* must not be null
|
||||
* @param logger for operation-level logging; must not be null
|
||||
* @throws NullPointerException if any parameter is null
|
||||
*/
|
||||
public DefaultResetDocumentStatusUseCase(
|
||||
UnitOfWorkPort unitOfWorkPort,
|
||||
ProcessingLogger logger) {
|
||||
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the processing status for the supplied set of document fingerprints.
|
||||
* <p>
|
||||
* Each fingerprint is processed independently. Technical failures for individual
|
||||
* fingerprints are caught, logged at error level, and recorded in the result;
|
||||
* they do not abort processing of the remaining fingerprints.
|
||||
*
|
||||
* @param fingerprints the set of document fingerprints to reset; must not be null;
|
||||
* may be empty
|
||||
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
|
||||
* @throws NullPointerException if {@code fingerprints} is null
|
||||
*/
|
||||
@Override
|
||||
public ResetDocumentStatusResult reset(Set<DocumentFingerprint> fingerprints) {
|
||||
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||
|
||||
int requestedCount = fingerprints.size();
|
||||
Set<DocumentFingerprint> successfullyReset = new HashSet<>();
|
||||
Map<DocumentFingerprint, String> failures = new HashMap<>();
|
||||
|
||||
for (DocumentFingerprint fingerprint : fingerprints) {
|
||||
try {
|
||||
unitOfWorkPort.executeInTransaction(
|
||||
tx -> tx.resetDocumentByFingerprint(fingerprint));
|
||||
successfullyReset.add(fingerprint);
|
||||
logger.info("Document status reset for fingerprint: {}", fingerprint.sha256Hex());
|
||||
} catch (DocumentPersistenceException e) {
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||
failures.put(fingerprint, errorMessage);
|
||||
logger.error("Failed to reset document status for fingerprint {}: {}",
|
||||
fingerprint.sha256Hex(), errorMessage, e);
|
||||
} catch (RuntimeException e) {
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||
failures.put(fingerprint, errorMessage);
|
||||
logger.error("Unexpected error resetting document status for fingerprint {}: {}",
|
||||
fingerprint.sha256Hex(), errorMessage, e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Status-Reset abgeschlossen: {} angefordert, {} erfolgreich, {} fehlgeschlagen.",
|
||||
requestedCount, successfullyReset.size(), failures.size());
|
||||
|
||||
return new ResetDocumentStatusResult(requestedCount, successfullyReset, failures);
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -1335,6 +1335,11 @@ class DocumentProcessingCoordinatorTest {
|
||||
public void update(DocumentRecord record) {
|
||||
updatedRecords.add(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
}
|
||||
|
||||
private static class CapturingProcessingAttemptRepository implements ProcessingAttemptRepository {
|
||||
@@ -1367,6 +1372,11 @@ class DocumentProcessingCoordinatorTest {
|
||||
.reduce((first, second) -> second)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
}
|
||||
|
||||
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
|
||||
@@ -1391,16 +1401,21 @@ class DocumentProcessingCoordinatorTest {
|
||||
public void saveProcessingAttempt(ProcessingAttempt attempt) {
|
||||
attemptRepo.savedAttempts.add(attempt);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void createDocumentRecord(DocumentRecord record) {
|
||||
recordRepo.createdRecords.add(record);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void updateDocumentRecord(DocumentRecord record) {
|
||||
recordRepo.updatedRecords.add(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
};
|
||||
|
||||
operations.accept(mockOps);
|
||||
@@ -1441,7 +1456,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||
return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
|
||||
}
|
||||
|
||||
@@ -1490,7 +1505,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||
return new ResolvedTargetFilename(baseName);
|
||||
}
|
||||
|
||||
@@ -1507,7 +1522,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||
return new ResolvedTargetFilename(baseName);
|
||||
}
|
||||
|
||||
|
||||
+110
-1
@@ -968,6 +968,85 @@ class BatchRunProcessingUseCaseTest {
|
||||
+ "Captured messages: " + capturingLogger.allMessages());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fingerprint filter (mini-run) behaviour
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void execute_withFingerprintFilter_processesOnlyMatchingCandidates() throws Exception {
|
||||
// Three candidates in the source folder
|
||||
SourceDocumentCandidate c1 = makeCandidate("doc1.pdf");
|
||||
SourceDocumentCandidate c2 = makeCandidate("doc2.pdf");
|
||||
SourceDocumentCandidate c3 = makeCandidate("doc3.pdf");
|
||||
|
||||
AlwaysSuccessFingerprintPort fpPort = new AlwaysSuccessFingerprintPort();
|
||||
DocumentFingerprint fp1 = ((FingerprintSuccess) fpPort.computeFingerprint(c1)).fingerprint();
|
||||
DocumentFingerprint fp3 = ((FingerprintSuccess) fpPort.computeFingerprint(c3)).fingerprint();
|
||||
|
||||
// Filter selects only c1 and c3
|
||||
java.util.Set<DocumentFingerprint> filter = java.util.Set.of(fp1, fp3);
|
||||
|
||||
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||
RuntimeConfiguration config = buildConfig(tempDir);
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
config, new MockRunLockPort(),
|
||||
new FixedCandidatesPort(List.of(c1, c2, c3)),
|
||||
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||
fpPort, coordinator);
|
||||
|
||||
BatchRunContext filteredContext = new BatchRunContext(new RunId("filter-run"), Instant.now())
|
||||
.withFingerprintFilter(filter);
|
||||
BatchRunOutcome outcome = useCase.execute(filteredContext);
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
// Only c1 and c3 must reach the coordinator (c2 skipped)
|
||||
assertEquals(2, coordinator.processCallCount(),
|
||||
"Only the 2 filtered candidates should reach the coordinator; c2 must be skipped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_withFingerprintFilter_emptyFilter_processesNothing() throws Exception {
|
||||
SourceDocumentCandidate c1 = makeCandidate("docA.pdf");
|
||||
|
||||
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||
RuntimeConfiguration config = buildConfig(tempDir);
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
config, new MockRunLockPort(),
|
||||
new FixedCandidatesPort(List.of(c1)),
|
||||
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||
new AlwaysSuccessFingerprintPort(), coordinator);
|
||||
|
||||
BatchRunContext filteredContext = new BatchRunContext(new RunId("empty-filter-run"), Instant.now())
|
||||
.withFingerprintFilter(java.util.Set.of());
|
||||
BatchRunOutcome outcome = useCase.execute(filteredContext);
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
assertEquals(0, coordinator.processCallCount(),
|
||||
"Empty filter must result in no documents being processed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_withoutFingerprintFilter_processesAllCandidates() throws Exception {
|
||||
SourceDocumentCandidate c1 = makeCandidate("all1.pdf");
|
||||
SourceDocumentCandidate c2 = makeCandidate("all2.pdf");
|
||||
|
||||
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||
RuntimeConfiguration config = buildConfig(tempDir);
|
||||
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||
config, new MockRunLockPort(),
|
||||
new FixedCandidatesPort(List.of(c1, c2)),
|
||||
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||
new AlwaysSuccessFingerprintPort(), coordinator);
|
||||
|
||||
// No filter → regular run
|
||||
BatchRunContext regularContext = new BatchRunContext(new RunId("regular-run"), Instant.now());
|
||||
BatchRunOutcome outcome = useCase.execute(regularContext);
|
||||
|
||||
assertTrue(outcome.isSuccess());
|
||||
assertEquals(2, coordinator.processCallCount(),
|
||||
"Without a filter all candidates must reach the coordinator");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1209,7 +1288,7 @@ class BatchRunProcessingUseCaseTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||
return new ResolvedTargetFilename(baseName);
|
||||
}
|
||||
|
||||
@@ -1245,6 +1324,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void update(DocumentRecord record) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
/** No-op ProcessingAttemptRepository for use in test instances. */
|
||||
@@ -1268,6 +1352,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
/** No-op UnitOfWorkPort for use in test instances. */
|
||||
@@ -1290,6 +1379,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void updateDocumentRecord(DocumentRecord record) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1417,6 +1511,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void update(DocumentRecord record) {
|
||||
updatedRecords.add(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1450,6 +1549,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
.reduce((first, second) -> second)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1483,6 +1587,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void updateDocumentRecord(DocumentRecord record) {
|
||||
recordRepo.update(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -83,23 +83,26 @@ class BatchRunProgressObservationTest {
|
||||
// Value object invariants
|
||||
// =========================================================================
|
||||
|
||||
private static final DocumentFingerprint DUMMY_FP =
|
||||
new DocumentFingerprint("a".repeat(64));
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_rejectsBlankFilename() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||
" ", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||
" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_rejectsNegativeDuration() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SUCCESS, null, null, null,
|
||||
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null,
|
||||
Duration.ofSeconds(-1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentCompletionEvent_carriesOptionalFields() {
|
||||
DocumentCompletionEvent event = new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
||||
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
||||
LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
|
||||
|
||||
assertEquals("x.pdf", event.originalFileName());
|
||||
@@ -130,7 +133,7 @@ class BatchRunProgressObservationTest {
|
||||
assertSame(a, b);
|
||||
a.onRunStarted(new RunId("r-1"), 5);
|
||||
a.onDocumentCompleted(new DocumentCompletionEvent(
|
||||
"x.pdf", DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
||||
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
||||
a.onRunEnded(new RunSummary(0, 0, 0));
|
||||
}
|
||||
|
||||
@@ -437,6 +440,7 @@ class BatchRunProgressObservationTest {
|
||||
if (index < statuses.size() && currentForwarder != null) {
|
||||
currentForwarder.accept(new DocumentCompletionEvent(
|
||||
candidate.uniqueIdentifier(),
|
||||
fingerprint,
|
||||
statuses.get(index),
|
||||
null, null, null, Duration.ofMillis(10)));
|
||||
}
|
||||
@@ -455,6 +459,7 @@ class BatchRunProgressObservationTest {
|
||||
}
|
||||
@Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
@Override public void deleteByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
|
||||
private static final class NoAttempts implements ProcessingAttemptRepository {
|
||||
@@ -466,6 +471,7 @@ class BatchRunProgressObservationTest {
|
||||
}
|
||||
@Override public ProcessingAttempt findLatestProposalReadyAttempt(
|
||||
DocumentFingerprint fingerprint) { return null; }
|
||||
@Override public void deleteAllByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
|
||||
private static final class NoUow implements UnitOfWorkPort {
|
||||
@@ -480,7 +486,8 @@ class BatchRunProgressObservationTest {
|
||||
|
||||
private static final class NoTargetFolder implements TargetFolderPort {
|
||||
static final NoTargetFolder INSTANCE = new NoTargetFolder();
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseFilename) {
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(
|
||||
String baseFilename, DocumentFingerprint sourceFingerprint) {
|
||||
return new ResolvedTargetFilename(baseFilename);
|
||||
}
|
||||
@Override public String getTargetFolderLocator() { return "/tmp/target"; }
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Tests for {@link DefaultResetDocumentStatusUseCase}.
|
||||
* <p>
|
||||
* Covers the happy path, per-fingerprint failure isolation, empty-set handling,
|
||||
* null-guard on the fingerprint set, and best-effort continuation after failure.
|
||||
*/
|
||||
class DefaultResetDocumentStatusUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FP1 =
|
||||
new DocumentFingerprint("1".repeat(64));
|
||||
private static final DocumentFingerprint FP2 =
|
||||
new DocumentFingerprint("2".repeat(64));
|
||||
private static final DocumentFingerprint FP3 =
|
||||
new DocumentFingerprint("3".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_allSucceed_returnsFullSuccessResult() {
|
||||
List<DocumentFingerprint> resetFingerprints = new ArrayList<>();
|
||||
UnitOfWorkPort alwaysSucceeds = ops -> ops.accept(
|
||||
new RecordingTransactionOperations(resetFingerprints));
|
||||
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(alwaysSucceeds, noOpLogger());
|
||||
|
||||
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2));
|
||||
|
||||
assertThat(result.requestedCount()).isEqualTo(2);
|
||||
assertThat(result.successCount()).isEqualTo(2);
|
||||
assertThat(result.failureCount()).isEqualTo(0);
|
||||
assertThat(result.successfullyReset()).containsExactlyInAnyOrder(FP1, FP2);
|
||||
assertThat(result.failures()).isEmpty();
|
||||
assertThat(resetFingerprints).containsExactlyInAnyOrder(FP1, FP2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty set
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_emptySet_returnsEmptyResult() {
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(ops -> { }, noOpLogger());
|
||||
|
||||
ResetDocumentStatusResult result = useCase.reset(Set.of());
|
||||
|
||||
assertThat(result.requestedCount()).isEqualTo(0);
|
||||
assertThat(result.successCount()).isEqualTo(0);
|
||||
assertThat(result.failureCount()).isEqualTo(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Failure isolation: DocumentPersistenceException on one fingerprint
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_oneFailsWithPersistenceException_othersSucceed() {
|
||||
List<DocumentFingerprint> resetFingerprints = new ArrayList<>();
|
||||
UnitOfWorkPort failsForFp2 = ops -> {
|
||||
RecordingTransactionOperations txOps =
|
||||
new RecordingTransactionOperations(resetFingerprints);
|
||||
ops.accept(txOps);
|
||||
// If FP2 was the last one added, throw
|
||||
if (!resetFingerprints.isEmpty()
|
||||
&& resetFingerprints.getLast().sha256Hex().equals(FP2.sha256Hex())) {
|
||||
resetFingerprints.remove(resetFingerprints.size() - 1);
|
||||
throw new DocumentPersistenceException("Simulated persistence failure for FP2");
|
||||
}
|
||||
};
|
||||
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(failsForFp2, noOpLogger());
|
||||
|
||||
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2));
|
||||
|
||||
assertThat(result.requestedCount()).isEqualTo(2);
|
||||
// One succeeded, one failed
|
||||
assertThat(result.successCount() + result.failureCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Failure isolation: best-effort, all fingerprints attempted after failure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_firstFingerprintFails_remainingFingerprintsStillAttempted() {
|
||||
List<DocumentFingerprint> attempted = new ArrayList<>();
|
||||
int[] callCount = {0};
|
||||
UnitOfWorkPort failsFirstCall = ops -> {
|
||||
callCount[0]++;
|
||||
RecordingTransactionOperations txOps = new RecordingTransactionOperations(attempted);
|
||||
ops.accept(txOps);
|
||||
if (callCount[0] == 1) {
|
||||
attempted.remove(attempted.size() - 1); // undo
|
||||
throw new DocumentPersistenceException("First call fails");
|
||||
}
|
||||
};
|
||||
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(failsFirstCall, noOpLogger());
|
||||
|
||||
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2, FP3));
|
||||
|
||||
// All 3 were attempted
|
||||
assertThat(result.requestedCount()).isEqualTo(3);
|
||||
assertThat(callCount[0]).isEqualTo(3);
|
||||
// Exactly 1 failure (the first call)
|
||||
assertThat(result.failureCount()).isEqualTo(1);
|
||||
assertThat(result.successCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RuntimeException isolation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_unexpectedRuntimeException_recordedAsFailure() {
|
||||
UnitOfWorkPort throwsRuntime = ops -> {
|
||||
throw new IllegalStateException("unexpected");
|
||||
};
|
||||
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(throwsRuntime, noOpLogger());
|
||||
|
||||
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1));
|
||||
|
||||
assertThat(result.requestedCount()).isEqualTo(1);
|
||||
assertThat(result.failureCount()).isEqualTo(1);
|
||||
assertThat(result.successCount()).isEqualTo(0);
|
||||
assertThat(result.failures()).containsKey(FP1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Null guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void reset_nullFingerprintSet_throwsNullPointerException() {
|
||||
DefaultResetDocumentStatusUseCase useCase =
|
||||
new DefaultResetDocumentStatusUseCase(ops -> { }, noOpLogger());
|
||||
assertThatNullPointerException().isThrownBy(() -> useCase.reset(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullUnitOfWorkPort() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultResetDocumentStatusUseCase(null, noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullLogger() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultResetDocumentStatusUseCase(ops -> { }, null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static ProcessingLogger noOpLogger() {
|
||||
return new ProcessingLogger() {
|
||||
@Override public void info(String msg, Object... args) { }
|
||||
@Override public void debug(String msg, Object... args) { }
|
||||
@Override public void debugSensitiveAiContent(String msg, Object... args) { }
|
||||
@Override public void warn(String msg, Object... args) { }
|
||||
@Override public void error(String msg, Object... args) { }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records each {@code resetDocumentByFingerprint} call for assertion.
|
||||
* Other transaction operations are no-ops (tests never reach them here).
|
||||
*/
|
||||
private static class RecordingTransactionOperations
|
||||
implements UnitOfWorkPort.TransactionOperations {
|
||||
|
||||
private final List<DocumentFingerprint> recorded;
|
||||
|
||||
RecordingTransactionOperations(List<DocumentFingerprint> recorded) {
|
||||
this.recorded = recorded;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveProcessingAttempt(
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt attempt) { }
|
||||
|
||||
@Override
|
||||
public void createDocumentRecord(
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
|
||||
@Override
|
||||
public void updateDocumentRecord(
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
recorded.add(fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user