Fix #31: Manuelle Dateinamen-Eingabe für nicht verarbeitete Dateien
Nicht-erfolgreiche Zeilen (FAILED, FAILED_RETRYABLE, SKIPPED_FINAL_FAILURE) können im Detailbereich des Verarbeitungslauf-Tabs nun einen manuellen Zieldateinamen erhalten. Beim Bestätigen wird die Quelldatei mit dem benutzerdefinierten Namen ins Zielverzeichnis kopiert und der Stammsatz atomar auf SUCCESS gehoben. Neuer Inbound-Port ManualFileCopyUseCase mit sealed Result-Hierarchie, Default-Implementierung mit Best-Effort-Rollback bei Persistenzfehler sowie GUI-Brücke GuiManualFileCopyPort. Die GUI entscheidet anhand des Status zwischen Umbenennen (SUCCESS, SKIPPED_ALREADY_PROCESSED) und Kopieren (FAILED_*, SKIPPED_FINAL_FAILURE). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis, wenn das zu kopierende Dokument in der Persistenz nicht gefunden wurde.
|
||||
* <p>
|
||||
* Gibt an, dass kein Dokument-Stammsatz mit dem angegebenen Fingerprint existiert.
|
||||
* Dies kann eintreten, wenn der Fingerprint ungültig ist oder der Datensatz
|
||||
* zwischenzeitlich gelöscht wurde.
|
||||
*
|
||||
* @param reason menschenlesbare Begründung, warum das Dokument nicht gefunden wurde;
|
||||
* nie null
|
||||
*/
|
||||
public record ManualFileCopyDocumentNotFound(String reason) implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code reason} null ist
|
||||
*/
|
||||
public ManualFileCopyDocumentNotFound {
|
||||
Objects.requireNonNull(reason, "reason must not be null");
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis, wenn die Kopie der Quelldatei ins Zielverzeichnis im Dateisystem
|
||||
* fehlgeschlagen ist.
|
||||
* <p>
|
||||
* Gibt an, dass ein technischer Fehler beim Dateisystemzugriff aufgetreten ist,
|
||||
* z. B. fehlende Schreibrechte, gesperrte Datei durch einen anderen Prozess oder
|
||||
* ein nicht erreichbares Netzlaufwerk. Ebenfalls verwendet, wenn der Zielordner-Port
|
||||
* einen technischen Fehler meldet.
|
||||
* <p>
|
||||
* Gemäß dem Alles-oder-Nichts-Prinzip wird in diesem Fall die Persistenz nicht
|
||||
* aktualisiert.
|
||||
*
|
||||
* @param message menschenlesbare Beschreibung des aufgetretenen Fehlers; nie null
|
||||
*/
|
||||
public record ManualFileCopyFileSystemFailure(String message) implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code message} null ist
|
||||
*/
|
||||
public ManualFileCopyFileSystemFailure {
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis, wenn das Dokument sich in einem ungültigen Zustand für eine manuelle
|
||||
* Kopie befindet.
|
||||
* <p>
|
||||
* Eine manuelle Kopie ist nur für Dokumente sinnvoll, deren Quelldatei noch nicht
|
||||
* erfolgreich ins Zielverzeichnis kopiert wurde. Dieses Ergebnis wird zurückgegeben,
|
||||
* wenn z. B.:
|
||||
* <ul>
|
||||
* <li>der Dokumentstatus bereits {@code SUCCESS} ist (in diesem Fall ist eine
|
||||
* Umbenennung der existierenden Zieldatei vorgesehen, keine neue Kopie), oder</li>
|
||||
* <li>im Stammsatz keine verwertbare Quelldatei-Information hinterlegt ist.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param reason menschenlesbare Begründung für den ungültigen Zustand; nie null
|
||||
*/
|
||||
public record ManualFileCopyInvalidState(String reason) implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code reason} null ist
|
||||
*/
|
||||
public ManualFileCopyInvalidState {
|
||||
Objects.requireNonNull(reason, "reason must not be null");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis, wenn keine Kopie notwendig ist, weil im Zielverzeichnis bereits eine Datei
|
||||
* mit identischem Inhalt (gleicher Fingerprint) vorhanden ist.
|
||||
* <p>
|
||||
* Der Dokument-Stammsatz wird in diesem Fall trotzdem konsistent auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} gehoben und
|
||||
* der vorhandene Zieldateiname wird übernommen, sodass das Dokument fachlich als
|
||||
* abgeschlossen gilt.
|
||||
*
|
||||
* @param existingFileName der Dateiname (ohne Pfad) der bereits vorhandenen identischen
|
||||
* Zieldatei; nie null
|
||||
*/
|
||||
public record ManualFileCopyNoOpIdenticalTarget(String existingFileName)
|
||||
implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code existingFileName} null ist
|
||||
*/
|
||||
public ManualFileCopyNoOpIdenticalTarget {
|
||||
Objects.requireNonNull(existingFileName, "existingFileName must not be null");
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis, wenn die Persistenzaktualisierung nach erfolgreicher Dateikopie
|
||||
* fehlgeschlagen ist.
|
||||
* <p>
|
||||
* Gibt an, dass die Quelldatei zwar erfolgreich ins Zielverzeichnis kopiert werden
|
||||
* konnte, jedoch die anschließende Aktualisierung des Dokument-Stammsatzes in der
|
||||
* Persistenz fehlgeschlagen ist. Der Use-Case versucht in diesem Fall, die
|
||||
* geschriebene Zieldatei wieder zu entfernen (Best-Effort-Rollback).
|
||||
* <p>
|
||||
* Schlägt auch der Rollback fehl, wird dies auf ERROR-Ebene protokolliert. In jedem
|
||||
* Fall bleibt dieses Ergebnis die Rückgabe, sodass der Aufrufer den Benutzer
|
||||
* informieren kann.
|
||||
*
|
||||
* @param message menschenlesbare Beschreibung des Persistenzfehlers; nie null
|
||||
*/
|
||||
public record ManualFileCopyPersistenceFailure(String message) implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung des Pflichtfelds.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code message} null ist
|
||||
*/
|
||||
public ManualFileCopyPersistenceFailure {
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Anfrage an den {@link ManualFileCopyUseCase} zum manuellen Kopieren der Quelldatei
|
||||
* eines bisher nicht erfolgreich verarbeiteten Dokuments mit einem benutzerdefinierten
|
||||
* Zieldateinamen.
|
||||
* <p>
|
||||
* Der Benutzer gibt im GUI ausschließlich den Basistitel ohne {@code .pdf}-Endung an.
|
||||
* Der Use-Case hängt die Erweiterung selbst an.
|
||||
*
|
||||
* @param fingerprint Inhalts-Fingerabdruck des Dokuments, dessen Quelldatei
|
||||
* kopiert werden soll; nie null
|
||||
* @param desiredBaseFileName gewünschter Basisdateiname ohne {@code .pdf}-Endung;
|
||||
* nie null; darf nicht leer oder nur aus Leerzeichen bestehen
|
||||
*/
|
||||
public record ManualFileCopyRequest(
|
||||
DocumentFingerprint fingerprint,
|
||||
String desiredBaseFileName) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung der Pflichtfelder.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code fingerprint} oder
|
||||
* {@code desiredBaseFileName} null sind
|
||||
* @throws IllegalArgumentException wenn {@code desiredBaseFileName} leer ist
|
||||
*/
|
||||
public ManualFileCopyRequest {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||
Objects.requireNonNull(desiredBaseFileName, "desiredBaseFileName must not be null");
|
||||
if (desiredBaseFileName.isBlank()) {
|
||||
throw new IllegalArgumentException("desiredBaseFileName must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Versiegeltes Ergebnis-Interface für eine manuelle Dateikopie via
|
||||
* {@link ManualFileCopyUseCase}.
|
||||
* <p>
|
||||
* Mögliche Ergebnisse:
|
||||
* <ul>
|
||||
* <li>{@link ManualFileCopySuccess} – die Quelldatei wurde unter dem gewünschten
|
||||
* Namen (ggf. mit Suffix) ins Zielverzeichnis kopiert und der Dokument-Stammsatz
|
||||
* auf {@code SUCCESS} aktualisiert.</li>
|
||||
* <li>{@link ManualFileCopyNoOpIdenticalTarget} – im Zielverzeichnis liegt bereits
|
||||
* eine Datei mit identischem Inhalt; es ist keine Kopie erforderlich, der
|
||||
* Stammsatz wurde dennoch konsistent auf {@code SUCCESS} gehoben.</li>
|
||||
* <li>{@link ManualFileCopyDocumentNotFound} – das Dokument wurde in der Persistenz
|
||||
* nicht gefunden.</li>
|
||||
* <li>{@link ManualFileCopyInvalidState} – das Dokument befindet sich in einem
|
||||
* ungültigen Zustand für eine manuelle Kopie (z. B. bereits {@code SUCCESS}).</li>
|
||||
* <li>{@link ManualFileCopyFileSystemFailure} – ein technischer Dateisystemfehler
|
||||
* ist während der Kopie aufgetreten (z. B. Quelldatei nicht vorhanden,
|
||||
* fehlende Schreibrechte, gesperrte Datei).</li>
|
||||
* <li>{@link ManualFileCopyPersistenceFailure} – die Persistenzaktualisierung ist
|
||||
* nach erfolgreicher Kopie fehlgeschlagen (Zieldatei wurde im Rahmen eines
|
||||
* Best-Effort-Rollbacks gelöscht).</li>
|
||||
* </ul>
|
||||
*/
|
||||
public sealed interface ManualFileCopyResult
|
||||
permits ManualFileCopySuccess,
|
||||
ManualFileCopyNoOpIdenticalTarget,
|
||||
ManualFileCopyDocumentNotFound,
|
||||
ManualFileCopyInvalidState,
|
||||
ManualFileCopyFileSystemFailure,
|
||||
ManualFileCopyPersistenceFailure {
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis einer erfolgreich abgeschlossenen manuellen Dateikopie.
|
||||
* <p>
|
||||
* Die Quelldatei wurde unter dem (ggf. mit Suffix versehenen) Zieldateinamen ins
|
||||
* Zielverzeichnis kopiert und der Dokument-Stammsatz wurde auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} aktualisiert.
|
||||
*
|
||||
* @param appliedFileName der tatsächlich angewendete Dateiname (ohne Pfad) der
|
||||
* Zielkopie; kann bei Konflikten ein Suffix wie {@code (1)}
|
||||
* enthalten; nie null
|
||||
* @param conflictSuffixApplied {@code true} wenn dem gewünschten Basisdateinamen ein
|
||||
* Konflikt-Suffix angehängt wurde, weil der Wunschname bereits
|
||||
* durch eine andere Datei belegt war
|
||||
*/
|
||||
public record ManualFileCopySuccess(
|
||||
String appliedFileName,
|
||||
boolean conflictSuffixApplied) implements ManualFileCopyResult {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor zur Validierung der Pflichtfelder.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code appliedFileName} null ist
|
||||
*/
|
||||
public ManualFileCopySuccess {
|
||||
Objects.requireNonNull(appliedFileName, "appliedFileName must not be null");
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||
|
||||
/**
|
||||
* Inbound-Port für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
|
||||
* verarbeiteten Dokuments mit benutzerdefiniertem Zieldateinamen.
|
||||
* <p>
|
||||
* Ermöglicht dem Benutzer, ein Dokument trotz fehlgeschlagener oder übersprungener
|
||||
* automatischer Verarbeitung manuell ins Zielverzeichnis zu überführen, ohne den
|
||||
* regulären KI-gestützten Verarbeitungspfad erneut anzustoßen. Der Use-Case führt die
|
||||
* Kopie als atomare Operation durch: Dateisystem und Persistenz werden entweder beide
|
||||
* konsistent aktualisiert oder beide bleiben im vorherigen Zustand.
|
||||
* <p>
|
||||
* <strong>Anwendungsbereich:</strong> Dokumente mit Status
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#FAILED_RETRYABLE},
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#FAILED_FINAL},
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#READY_FOR_AI} oder
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY}.
|
||||
* Für Dokumente mit Status
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS} ist
|
||||
* stattdessen {@link ManualFileRenameUseCase} zu verwenden, da dort bereits eine
|
||||
* Zieldatei existiert.
|
||||
* <p>
|
||||
* <strong>Konfliktsemantik:</strong> Existiert im Zielordner bereits eine Datei mit dem
|
||||
* gewünschten Namen, wird anhand des Inhalts-Fingerprints entschieden:
|
||||
* <ul>
|
||||
* <li>Gleicher Fingerprint → keine erneute Kopie ({@link ManualFileCopyNoOpIdenticalTarget}),
|
||||
* Stammsatz wird trotzdem auf {@code SUCCESS} gehoben.</li>
|
||||
* <li>Verschiedener Fingerprint → automatische Suffix-Vergabe ({@code (1)}, {@code (2)}, …).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Quellintegrität:</strong> Die Quelldatei wird nicht verändert, verschoben oder
|
||||
* gelöscht. Es entsteht ausschließlich eine Kopie im Zielordner.
|
||||
* <p>
|
||||
* <strong>Erfolg:</strong> Bei erfolgreicher Operation wechselt der Dokumentstatus auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}. Das Dokument
|
||||
* gilt damit fachlich als abgeschlossen und wird in zukünftigen Läufen mit
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SKIPPED_ALREADY_PROCESSED}
|
||||
* übersprungen.
|
||||
*/
|
||||
public interface ManualFileCopyUseCase {
|
||||
|
||||
/**
|
||||
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
|
||||
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}.
|
||||
* <p>
|
||||
* Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
|
||||
* aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach
|
||||
* erfolgreicher Kopie wird die Zieldatei im Rahmen eines Best-Effort-Rollbacks
|
||||
* wieder entfernt.
|
||||
*
|
||||
* @param request die Kopieranfrage mit Fingerprint und gewünschtem Basisdateinamen;
|
||||
* darf nicht null sein
|
||||
* @return das Ergebnis der Kopieroperation; nie null
|
||||
* @throws NullPointerException wenn {@code request} null ist
|
||||
*/
|
||||
ManualFileCopyResult copy(ManualFileCopyRequest request);
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopySuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyUseCase;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
|
||||
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.PersistenceLookupTechnicalFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
|
||||
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.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
|
||||
/**
|
||||
* Standardimplementierung von {@link ManualFileCopyUseCase}.
|
||||
* <p>
|
||||
* Führt die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich verarbeiteten
|
||||
* Dokuments ins Zielverzeichnis als atomare Operation durch: Entweder werden Dateisystem
|
||||
* und Persistenz beide aktualisiert, oder beide bleiben im vorherigen Zustand.
|
||||
* <p>
|
||||
* <strong>Ablauf:</strong>
|
||||
* <ol>
|
||||
* <li>Dokument-Stammsatz aus dem Repository laden und Zustand prüfen
|
||||
* (Status muss verarbeitbar oder final fehlgeschlagen sein, nicht {@code SUCCESS}).</li>
|
||||
* <li>Eindeutigen Zieldateinamen über {@link TargetFolderPort} auflösen.</li>
|
||||
* <li>Wenn der Zielordner bereits eine Datei mit identischem Inhalt enthält, wird
|
||||
* keine erneute Kopie geschrieben – der Stammsatz wird trotzdem konsistent
|
||||
* auf {@code SUCCESS} gehoben.</li>
|
||||
* <li>Andernfalls Quelldatei via {@link TargetFileCopyPort} unter dem aufgelösten
|
||||
* Namen ins Zielverzeichnis kopieren.</li>
|
||||
* <li>Dokument-Stammsatz in der Persistenz aktualisieren: Status auf
|
||||
* {@link ProcessingStatus#SUCCESS}, {@code lastTargetPath} und
|
||||
* {@code lastTargetFileName} setzen, {@code lastSuccessInstant} und
|
||||
* {@code updatedAt} aktualisieren.</li>
|
||||
* <li>Bei Persistenzfehler: Best-Effort-Rollback der geschriebenen Zieldatei.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Eine manuelle Kopie ist ausschließlich für Dokumente mit nicht-erfolgreichem
|
||||
* Status zulässig. Für Dokumente mit Status {@link ProcessingStatus#SUCCESS}
|
||||
* ist die manuelle Umbenennung der bestehenden Zieldatei vorgesehen.
|
||||
*/
|
||||
public class DefaultManualFileCopyUseCase implements ManualFileCopyUseCase {
|
||||
|
||||
private final DocumentRecordRepository repository;
|
||||
private final TargetFolderPort targetFolderPort;
|
||||
private final TargetFileCopyPort targetFileCopyPort;
|
||||
private final UnitOfWorkPort unitOfWorkPort;
|
||||
private final ClockPort clock;
|
||||
private final ProcessingLogger logger;
|
||||
|
||||
/**
|
||||
* Erstellt den Use-Case mit allen erforderlichen Ports.
|
||||
*
|
||||
* @param repository Repository zum Lesen und Schreiben des Dokument-Stammsatzes;
|
||||
* darf nicht null sein
|
||||
* @param targetFolderPort Port zur Auflösung eindeutiger Zieldateinamen sowie
|
||||
* Best-Effort-Aufräumen einer geschriebenen Zieldatei;
|
||||
* darf nicht null sein
|
||||
* @param targetFileCopyPort Port zum physischen Kopieren der Quelldatei in den
|
||||
* Zielordner; darf nicht null sein
|
||||
* @param unitOfWorkPort Port zur atomaren Persistenzaktualisierung;
|
||||
* darf nicht null sein
|
||||
* @param clock Port zur Abfrage des aktuellen Zeitstempels;
|
||||
* darf nicht null sein
|
||||
* @param logger für die Protokollierung von Betriebsereignissen;
|
||||
* darf nicht null sein
|
||||
* @throws NullPointerException wenn einer der Parameter null ist
|
||||
*/
|
||||
public DefaultManualFileCopyUseCase(
|
||||
DocumentRecordRepository repository,
|
||||
TargetFolderPort targetFolderPort,
|
||||
TargetFileCopyPort targetFileCopyPort,
|
||||
UnitOfWorkPort unitOfWorkPort,
|
||||
ClockPort clock,
|
||||
ProcessingLogger logger) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository must not be null");
|
||||
this.targetFolderPort = Objects.requireNonNull(targetFolderPort, "targetFolderPort must not be null");
|
||||
this.targetFileCopyPort = Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
|
||||
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
|
||||
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}.
|
||||
* <p>
|
||||
* Der Aufruf ist atomar: Entweder werden Dateisystem und Persistenz beide
|
||||
* aktualisiert, oder beide bleiben unverändert. Bei einem Persistenzfehler nach
|
||||
* erfolgreicher Kopie wird die geschriebene Zieldatei im Rahmen eines Best-Effort-
|
||||
* Rollbacks wieder entfernt.
|
||||
*
|
||||
* @param request die Kopieranfrage mit Fingerprint und gewünschtem Basisdateinamen;
|
||||
* darf nicht null sein
|
||||
* @return das Ergebnis der Kopieroperation; nie null
|
||||
* @throws NullPointerException wenn {@code request} null ist
|
||||
*/
|
||||
@Override
|
||||
public ManualFileCopyResult copy(ManualFileCopyRequest request) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
|
||||
DocumentFingerprint fingerprint = request.fingerprint();
|
||||
String desiredFullName = request.desiredBaseFileName() + ".pdf";
|
||||
|
||||
logger.info("Manuelle Dateikopie angefordert: Fingerprint={}, Zielname={}",
|
||||
fingerprint.sha256Hex(), desiredFullName);
|
||||
|
||||
// Schritt 1: Dokument-Stammsatz laden und Zustand prüfen
|
||||
DocumentRecordLookupResult lookupResult = repository.findByFingerprint(fingerprint);
|
||||
|
||||
DocumentRecord record;
|
||||
if (lookupResult instanceof DocumentTerminalFinalFailure terminalFailure) {
|
||||
record = terminalFailure.record();
|
||||
} else if (lookupResult instanceof DocumentKnownProcessable known) {
|
||||
record = known.record();
|
||||
ProcessingStatus status = record.overallStatus();
|
||||
if (status == ProcessingStatus.SUCCESS) {
|
||||
// Defensiv: SUCCESS sollte über DocumentTerminalSuccess auflösen, nicht hier.
|
||||
logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}",
|
||||
fingerprint.sha256Hex());
|
||||
return new ManualFileCopyInvalidState(
|
||||
"Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der "
|
||||
+ "Zieldatei verwenden.");
|
||||
}
|
||||
} else if (lookupResult instanceof DocumentTerminalSuccess) {
|
||||
logger.warn("Manuelle Dateikopie verweigert: Dokument bereits SUCCESS. Fingerprint={}",
|
||||
fingerprint.sha256Hex());
|
||||
return new ManualFileCopyInvalidState(
|
||||
"Dokument ist bereits erfolgreich verarbeitet. Bitte die Umbenennung der "
|
||||
+ "Zieldatei verwenden.");
|
||||
} else if (lookupResult instanceof DocumentUnknown) {
|
||||
logger.warn("Manuelle Dateikopie verweigert: Dokument unbekannt. Fingerprint={}",
|
||||
fingerprint.sha256Hex());
|
||||
return new ManualFileCopyDocumentNotFound(
|
||||
"Kein Dokument mit dem angegebenen Fingerprint gefunden.");
|
||||
} else if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) {
|
||||
logger.warn("Manuelle Dateikopie fehlgeschlagen: Lookup-Fehler. Fingerprint={}, Ursache={}",
|
||||
fingerprint.sha256Hex(), failure.errorMessage());
|
||||
return new ManualFileCopyPersistenceFailure(
|
||||
"Persistenzfehler beim Laden des Dokumentstammsatzes: " + failure.errorMessage());
|
||||
} else {
|
||||
// Defensiv: nicht erreichbar dank sealed type, aber erforderlich für die Compiler-
|
||||
// Vollständigkeitsprüfung in älteren Werkzeugen.
|
||||
return new ManualFileCopyDocumentNotFound(
|
||||
"Unbekanntes Lookup-Ergebnis: " + lookupResult.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// Schritt 2: Eindeutigen Zieldateinamen über TargetFolderPort auflösen
|
||||
TargetFilenameResolutionResult resolutionResult =
|
||||
targetFolderPort.resolveUniqueFilename(desiredFullName, fingerprint);
|
||||
|
||||
boolean noOpIdentical = false;
|
||||
String appliedFileName;
|
||||
|
||||
if (resolutionResult instanceof ExistingIdenticalTargetFile identical) {
|
||||
noOpIdentical = true;
|
||||
appliedFileName = identical.existingFilename();
|
||||
logger.info("Manuelle Dateikopie: Identische Datei bereits im Zielordner vorhanden. Fingerprint={}",
|
||||
fingerprint.sha256Hex());
|
||||
} else if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
|
||||
logger.warn("Manuelle Dateikopie fehlgeschlagen: Zielordnerzugriff. Fingerprint={}, Ursache={}",
|
||||
fingerprint.sha256Hex(), folderFailure.errorMessage());
|
||||
return new ManualFileCopyFileSystemFailure(
|
||||
"Zielordner nicht zugänglich: " + folderFailure.errorMessage());
|
||||
} else if (resolutionResult instanceof ResolvedTargetFilename resolved) {
|
||||
appliedFileName = resolved.resolvedFilename();
|
||||
} else {
|
||||
return new ManualFileCopyFileSystemFailure(
|
||||
"Unbekanntes Auflösungsergebnis: " + resolutionResult.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// Schritt 3: Quelldatei kopieren – nur wenn keine identische Zieldatei existiert
|
||||
if (!noOpIdentical) {
|
||||
var copyResult = targetFileCopyPort.copyToTarget(
|
||||
record.lastKnownSourceLocator(), appliedFileName);
|
||||
if (copyResult instanceof TargetFileCopyTechnicalFailure technicalFailure) {
|
||||
logger.warn("Manuelle Dateikopie fehlgeschlagen: Dateisystemfehler. Fingerprint={}, Ursache={}",
|
||||
fingerprint.sha256Hex(), technicalFailure.errorMessage());
|
||||
return new ManualFileCopyFileSystemFailure(technicalFailure.errorMessage());
|
||||
}
|
||||
if (!(copyResult instanceof TargetFileCopySuccess)) {
|
||||
return new ManualFileCopyFileSystemFailure(
|
||||
"Unbekanntes Kopier-Ergebnis: " + copyResult.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
// Schritt 4: Dokument-Stammsatz aktualisieren
|
||||
var now = clock.now();
|
||||
DocumentRecord updatedRecord = new DocumentRecord(
|
||||
record.fingerprint(),
|
||||
record.lastKnownSourceLocator(),
|
||||
record.lastKnownSourceFileName(),
|
||||
ProcessingStatus.SUCCESS,
|
||||
record.failureCounters(),
|
||||
record.lastFailureInstant(),
|
||||
now,
|
||||
record.createdAt(),
|
||||
now,
|
||||
targetFolderPort.getTargetFolderLocator(),
|
||||
appliedFileName);
|
||||
|
||||
try {
|
||||
unitOfWorkPort.executeInTransaction(tx -> tx.updateDocumentRecord(updatedRecord));
|
||||
} catch (RuntimeException persistenceException) {
|
||||
String errorMessage = persistenceException.getMessage() != null
|
||||
? persistenceException.getMessage()
|
||||
: persistenceException.getClass().getSimpleName();
|
||||
|
||||
logger.warn("Manuelle Dateikopie: Persistenzfehler nach erfolgreicher Kopie. "
|
||||
+ "Versuche Rollback. Fingerprint={}, Ursache={}",
|
||||
fingerprint.sha256Hex(), errorMessage);
|
||||
|
||||
if (!noOpIdentical) {
|
||||
// Best-Effort-Rollback: nur die *neu* geschriebene Zieldatei entfernen,
|
||||
// niemals eine bereits zuvor vorhandene identische Datei.
|
||||
try {
|
||||
targetFolderPort.tryDeleteTargetFile(appliedFileName);
|
||||
} catch (RuntimeException rollbackException) {
|
||||
logger.error("Rollback der Zielkopie fehlgeschlagen: {}. "
|
||||
+ "Dateisystem und Persistenz sind möglicherweise inkonsistent. Fingerprint={}",
|
||||
appliedFileName, fingerprint.sha256Hex());
|
||||
}
|
||||
}
|
||||
|
||||
return new ManualFileCopyPersistenceFailure(
|
||||
"Persistenzfehler nach Kopie: " + errorMessage);
|
||||
}
|
||||
|
||||
boolean conflictSuffixApplied = !noOpIdentical && !appliedFileName.equals(desiredFullName);
|
||||
|
||||
if (noOpIdentical) {
|
||||
logger.info("Manuelle Dateikopie abgeschlossen ohne Schreibvorgang: identische Zieldatei {}.",
|
||||
appliedFileName);
|
||||
return new ManualFileCopyNoOpIdenticalTarget(appliedFileName);
|
||||
}
|
||||
|
||||
logger.info("Manuelle Dateikopie erfolgreich: {} (Suffix angewendet: {})",
|
||||
appliedFileName, conflictSuffixApplied);
|
||||
|
||||
return new ManualFileCopySuccess(appliedFileName, conflictSuffixApplied);
|
||||
}
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyDocumentNotFound;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyFileSystemFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyInvalidState;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyNoOpIdenticalTarget;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyPersistenceFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopySuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
|
||||
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.DocumentRecordLookupResult;
|
||||
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;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
|
||||
import de.gecheckt.pdf.umbenenner.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.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||
|
||||
/**
|
||||
* Tests für {@link DefaultManualFileCopyUseCase}.
|
||||
* <p>
|
||||
* Alle Mocks sind handgeschrieben (kein Mockito). Jeder Test prüft das zurückgegebene
|
||||
* Ergebnis und die für das atomare Verhalten relevanten Port-Aufrufe.
|
||||
*/
|
||||
class DefaultManualFileCopyUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FINGERPRINT =
|
||||
new DocumentFingerprint("b".repeat(64));
|
||||
|
||||
private static final String DESIRED_BASE = "2024-01-01 - Manuell benannt";
|
||||
private static final String DESIRED_FULL = DESIRED_BASE + ".pdf";
|
||||
|
||||
private static final Instant FIXED_NOW = Instant.parse("2024-06-01T10:00:00Z");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden zum Erstellen von Testdaten
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static DocumentRecord recordWithStatus(ProcessingStatus status) {
|
||||
return new DocumentRecord(
|
||||
FINGERPRINT,
|
||||
new SourceDocumentLocator("/quelle/datei.pdf"),
|
||||
"datei.pdf",
|
||||
status,
|
||||
FailureCounters.zero(),
|
||||
FIXED_NOW.minusSeconds(60),
|
||||
null,
|
||||
FIXED_NOW.minusSeconds(120),
|
||||
FIXED_NOW.minusSeconds(60),
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private static DocumentRecord successRecord() {
|
||||
return new DocumentRecord(
|
||||
FINGERPRINT,
|
||||
new SourceDocumentLocator("/quelle/datei.pdf"),
|
||||
"datei.pdf",
|
||||
ProcessingStatus.SUCCESS,
|
||||
FailureCounters.zero(),
|
||||
null,
|
||||
FIXED_NOW.minusSeconds(60),
|
||||
FIXED_NOW.minusSeconds(120),
|
||||
FIXED_NOW.minusSeconds(60),
|
||||
"/zielordner",
|
||||
"alt.pdf");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stub-Helfer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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) { }
|
||||
};
|
||||
}
|
||||
|
||||
private static ClockPort fixedClock() {
|
||||
return () -> FIXED_NOW;
|
||||
}
|
||||
|
||||
private static DocumentRecordRepository repositoryReturning(DocumentRecordLookupResult result) {
|
||||
return new DocumentRecordRepository() {
|
||||
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fp) { return result; }
|
||||
@Override public void create(DocumentRecord r) { }
|
||||
@Override public void update(DocumentRecord r) { }
|
||||
@Override public void deleteByFingerprint(DocumentFingerprint fp) { }
|
||||
};
|
||||
}
|
||||
|
||||
private static TargetFolderPort folderPortReturning(TargetFilenameResolutionResult result) {
|
||||
return new TargetFolderPort() {
|
||||
@Override public String getTargetFolderLocator() { return "/zielordner"; }
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) { return result; }
|
||||
@Override public void tryDeleteTargetFile(String name) { }
|
||||
};
|
||||
}
|
||||
|
||||
private static TargetFileCopyPort copyPortReturning(TargetFileCopyResult result) {
|
||||
return (sourceLocator, resolvedFilename) -> result;
|
||||
}
|
||||
|
||||
private static UnitOfWorkPort alwaysSucceedingUnitOfWork() {
|
||||
return ops -> ops.accept(new NoOpTransactionOperations());
|
||||
}
|
||||
|
||||
private static UnitOfWorkPort throwingUnitOfWork(RuntimeException ex) {
|
||||
return ops -> { throw ex; };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 1: Erfolgreicher Pfad ohne Konflikt (FAILED_FINAL als Eingangsstatus)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsSuccess_andUpdatesRecordToSuccess_whenNoConflict() {
|
||||
List<DocumentRecord> updatedRecords = new ArrayList<>();
|
||||
UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
uow,
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
|
||||
ManualFileCopySuccess success = (ManualFileCopySuccess) result;
|
||||
assertThat(success.appliedFileName()).isEqualTo(DESIRED_FULL);
|
||||
assertThat(success.conflictSuffixApplied()).isFalse();
|
||||
|
||||
assertThat(updatedRecords).hasSize(1);
|
||||
DocumentRecord updated = updatedRecords.get(0);
|
||||
assertThat(updated.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
|
||||
assertThat(updated.lastTargetFileName()).isEqualTo(DESIRED_FULL);
|
||||
assertThat(updated.lastTargetPath()).isEqualTo("/zielordner");
|
||||
assertThat(updated.lastSuccessInstant()).isEqualTo(FIXED_NOW);
|
||||
assertThat(updated.updatedAt()).isEqualTo(FIXED_NOW);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 2: Konflikt mit anderer Datei → Suffix angewendet
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_appliesSuffix_whenConflictWithDifferentFingerprint() {
|
||||
String suffixedName = DESIRED_BASE + "(1).pdf";
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPortReturning(new ResolvedTargetFilename(suffixedName)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
|
||||
ManualFileCopySuccess success = (ManualFileCopySuccess) result;
|
||||
assertThat(success.appliedFileName()).isEqualTo(suffixedName);
|
||||
assertThat(success.conflictSuffixApplied()).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 3: Identische Zieldatei vorhanden → No-Op, Stammsatz wird trotzdem
|
||||
// auf SUCCESS gehoben
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsNoOp_andUpdatesRecord_whenTargetFolderReportsIdenticalContent() {
|
||||
List<DocumentRecord> updatedRecords = new ArrayList<>();
|
||||
List<String> copyAttempts = new ArrayList<>();
|
||||
|
||||
TargetFileCopyPort copyPort = (locator, name) -> {
|
||||
copyAttempts.add(name);
|
||||
return new TargetFileCopySuccess();
|
||||
};
|
||||
UnitOfWorkPort uow = ops -> ops.accept(new RecordCapturingTransactionOperations(updatedRecords));
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPortReturning(new ExistingIdenticalTargetFile(DESIRED_FULL)),
|
||||
copyPort,
|
||||
uow,
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyNoOpIdenticalTarget.class);
|
||||
assertThat(((ManualFileCopyNoOpIdenticalTarget) result).existingFileName()).isEqualTo(DESIRED_FULL);
|
||||
|
||||
// Es darf KEIN Schreibvorgang erfolgen, wenn identischer Inhalt schon existiert
|
||||
assertThat(copyAttempts).isEmpty();
|
||||
|
||||
// Stammsatz wurde dennoch konsistent fortgeschrieben
|
||||
assertThat(updatedRecords).hasSize(1);
|
||||
assertThat(updatedRecords.get(0).overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
|
||||
assertThat(updatedRecords.get(0).lastTargetFileName()).isEqualTo(DESIRED_FULL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 4: Dokument unbekannt → DocumentNotFound
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsDocumentNotFound_whenRepositoryReturnsDocumentUnknown() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyDocumentNotFound.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 5: Dokument bereits SUCCESS → InvalidState
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsInvalidState_whenDocumentAlreadySuccess() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalSuccess(successRecord())),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyInvalidState.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 6: DocumentKnownProcessable mit FAILED_RETRYABLE → Erfolg
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_acceptsDocumentKnownProcessable_withFailedRetryable() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentKnownProcessable(
|
||||
recordWithStatus(ProcessingStatus.FAILED_RETRYABLE))),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopySuccess.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 7: Lookup-Fehler → PersistenceFailure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsPersistenceFailure_whenLookupFails() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new PersistenceLookupTechnicalFailure(
|
||||
"DB nicht erreichbar", new RuntimeException("boom"))),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 8: Zielordnerzugriff scheitert → FileSystemFailure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsFileSystemFailure_whenTargetFolderTechnicalFailure() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPortReturning(new TargetFolderTechnicalFailure("Laufwerk weg")),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class);
|
||||
assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Laufwerk weg");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 9: Kopie scheitert → FileSystemFailure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_returnsFileSystemFailure_whenCopyFails() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopyTechnicalFailure(
|
||||
"Quelldatei nicht lesbar", true)),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyFileSystemFailure.class);
|
||||
assertThat(((ManualFileCopyFileSystemFailure) result).message()).contains("Quelldatei nicht lesbar");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 10: Persistenzfehler nach erfolgreicher Kopie → Best-Effort-Rollback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_attemptsTargetFileDeletion_whenPersistenceFails() {
|
||||
List<String> deletedFiles = new ArrayList<>();
|
||||
TargetFolderPort folderPort = new TargetFolderPort() {
|
||||
@Override public String getTargetFolderLocator() { return "/zielordner"; }
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
|
||||
return new ResolvedTargetFilename(DESIRED_FULL);
|
||||
}
|
||||
@Override public void tryDeleteTargetFile(String name) {
|
||||
deletedFiles.add(name);
|
||||
}
|
||||
};
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPort,
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
|
||||
// Best-Effort-Rollback: gerade geschriebene Datei löschen
|
||||
assertThat(deletedFiles).containsExactly(DESIRED_FULL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 11: Persistenzfehler bei No-Op (identischer Inhalt) → KEIN Löschen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_doesNotDeleteIdenticalTargetFile_whenPersistenceFailsAfterNoOp() {
|
||||
List<String> deletedFiles = new ArrayList<>();
|
||||
TargetFolderPort folderPort = new TargetFolderPort() {
|
||||
@Override public String getTargetFolderLocator() { return "/zielordner"; }
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
|
||||
return new ExistingIdenticalTargetFile(DESIRED_FULL);
|
||||
}
|
||||
@Override public void tryDeleteTargetFile(String name) {
|
||||
deletedFiles.add(name);
|
||||
}
|
||||
};
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPort,
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
throwingUnitOfWork(new DocumentPersistenceException("DB explodiert")),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
ManualFileCopyResult result = useCase.copy(new ManualFileCopyRequest(FINGERPRINT, DESIRED_BASE));
|
||||
|
||||
assertThat(result).isInstanceOf(ManualFileCopyPersistenceFailure.class);
|
||||
// Identische, vor dem Lauf bereits vorhandene Datei darf nicht gelöscht werden
|
||||
assertThat(deletedFiles).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Testfall 12: .pdf-Erweiterung wird automatisch angehängt
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void copy_appendsPdfExtensionAutomatically() {
|
||||
List<String> baseNames = new ArrayList<>();
|
||||
TargetFolderPort folderPort = new TargetFolderPort() {
|
||||
@Override public String getTargetFolderLocator() { return "/zielordner"; }
|
||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint fp) {
|
||||
baseNames.add(baseName);
|
||||
return new ResolvedTargetFilename(baseName);
|
||||
}
|
||||
@Override public void tryDeleteTargetFile(String name) { }
|
||||
};
|
||||
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentTerminalFinalFailure(
|
||||
recordWithStatus(ProcessingStatus.FAILED_FINAL))),
|
||||
folderPort,
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
useCase.copy(new ManualFileCopyRequest(FINGERPRINT, "Ohne Erweiterung"));
|
||||
|
||||
assertThat(baseNames).hasSize(1);
|
||||
assertThat(baseNames.get(0)).isEqualTo("Ohne Erweiterung.pdf");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Konstruktor-Null-Guards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullRepository() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
null,
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullTargetFolderPort() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
null,
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullTargetFileCopyPort() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
null,
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullUnitOfWorkPort() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
null,
|
||||
fixedClock(),
|
||||
noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullClock() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
null,
|
||||
noOpLogger()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_rejectsNullLogger() {
|
||||
assertThatNullPointerException().isThrownBy(() -> new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void copy_rejectsNullRequest() {
|
||||
DefaultManualFileCopyUseCase useCase = new DefaultManualFileCopyUseCase(
|
||||
repositoryReturning(new DocumentUnknown()),
|
||||
folderPortReturning(new ResolvedTargetFilename(DESIRED_FULL)),
|
||||
copyPortReturning(new TargetFileCopySuccess()),
|
||||
alwaysSucceedingUnitOfWork(),
|
||||
fixedClock(),
|
||||
noOpLogger());
|
||||
|
||||
assertThatNullPointerException().isThrownBy(() -> useCase.copy(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsklassen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static class NoOpTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
|
||||
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
||||
private final List<DocumentRecord> captured;
|
||||
|
||||
RecordCapturingTransactionOperations(List<DocumentRecord> captured) {
|
||||
this.captured = captured;
|
||||
}
|
||||
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user