#7: Historien-Tab mit Liste, Detail, Filter, Status-Reset und Eintrag-Loeschen
Implementiert den vollstaendigen Historien-Tab (Verlauf) als vierten Tab der GUI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+23
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
|
||||
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||
*/
|
||||
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
||||
|
||||
/**
|
||||
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||
* ohne die Versuchshistorie zu löschen.
|
||||
* <p>
|
||||
* Folgende Felder werden aktualisiert:
|
||||
* <ul>
|
||||
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||
* <li>{@code content_error_count} → {@code 0}</li>
|
||||
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||
* </ul>
|
||||
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
|
||||
* {@code last_target_path}, {@code last_target_file_name} sowie alle
|
||||
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
|
||||
* <p>
|
||||
* Nach diesem Aufruf gilt das Dokument beim nächsten Lauf als verarbeitbar.
|
||||
*
|
||||
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||
* darf nicht {@code null} sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
*/
|
||||
void resetDocumentStatusForRetry(DocumentFingerprint fingerprint);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
|
||||
/**
|
||||
* Einzelzeile der Dokumentenliste im Historien-Tab.
|
||||
* <p>
|
||||
* Enthält alle Felder, die für die linke Tabelle des Historien-Tabs benötigt werden.
|
||||
* Die Felder stammen aus {@code document_record} und einem {@code COUNT}-Ausdruck über
|
||||
* {@code processing_attempt}.
|
||||
*
|
||||
* @param fingerprint Inhalts-basierter Dokumentbezeichner; nie {@code null}
|
||||
* @param overallStatus aktueller Gesamtstatus des Dokuments; nie {@code null}
|
||||
* @param sourceFileName zuletzt bekannter Quelldateiname; nie {@code null}
|
||||
* @param targetFileName zuletzt bekannter Zieldateiname; {@code null} falls noch kein
|
||||
* erfolgreicher Lauf stattgefunden hat
|
||||
* @param sourcePath zuletzt bekannter Quellpfad (opaker Locator-Wert); nie {@code null}
|
||||
* @param updatedAt Zeitpunkt der letzten Aktualisierung des Stammsatzes; nie {@code null}
|
||||
* @param attemptCount Anzahl historisierter Verarbeitungsversuche; immer >= 0
|
||||
*/
|
||||
public record DocumentHistoryRow(
|
||||
DocumentFingerprint fingerprint,
|
||||
ProcessingStatus overallStatus,
|
||||
String sourceFileName,
|
||||
String targetFileName,
|
||||
String sourcePath,
|
||||
Instant updatedAt,
|
||||
long attemptCount) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||
*
|
||||
* @throws NullPointerException wenn ein Pflichtfeld {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code attemptCount} negativ ist
|
||||
*/
|
||||
public DocumentHistoryRow {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
Objects.requireNonNull(overallStatus, "overallStatus darf nicht null sein");
|
||||
Objects.requireNonNull(sourceFileName, "sourceFileName darf nicht null sein");
|
||||
Objects.requireNonNull(sourcePath, "sourcePath darf nicht null sein");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt darf nicht null sein");
|
||||
if (attemptCount < 0) {
|
||||
throw new IllegalArgumentException("attemptCount darf nicht negativ sein, war: " + attemptCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||
|
||||
/**
|
||||
* Abfrageparameter für den Historien-Tab.
|
||||
* <p>
|
||||
* Kapselt Freitextsuche, optionalen Status-Filter und das Limit der zurückzugebenden
|
||||
* Zeilen. Das Limit ist bewusst auf 501 gesetzt, damit die aufrufende Schicht erkennen
|
||||
* kann, ob mehr als 500 Treffer vorhanden sind.
|
||||
*
|
||||
* @param searchText optionaler Suchbegriff (Teilstring, case-insensitiv); {@code null}
|
||||
* oder leer bedeutet keine Texteinschränkung
|
||||
* @param statusFilter optionaler Status-Filter als Enum-Name; {@code null} bedeutet alle
|
||||
* Status werden angezeigt
|
||||
* @param limit maximale Anzahl zurückzugebender Zeilen; muss >= 1 sein
|
||||
*/
|
||||
public record HistoryQuery(
|
||||
String searchText,
|
||||
String statusFilter,
|
||||
int limit) {
|
||||
|
||||
/**
|
||||
* Standard-Limit: 501 Zeilen abfragen, um bei Bedarf „mehr vorhanden" erkennen zu können.
|
||||
*/
|
||||
public static final int DEFAULT_LIMIT = 501;
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||
*
|
||||
* @throws IllegalArgumentException wenn {@code limit} kleiner als 1 ist
|
||||
*/
|
||||
public HistoryQuery {
|
||||
if (limit < 1) {
|
||||
throw new IllegalArgumentException("limit muss mindestens 1 sein, war: " + limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine Abfrage ohne Filter mit Standard-Limit.
|
||||
*
|
||||
* @return neue Abfrage ohne Einschränkungen
|
||||
*/
|
||||
public static HistoryQuery unfiltered() {
|
||||
return new HistoryQuery(null, null, DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine Abfrage mit Freitextsuche und Standard-Limit.
|
||||
*
|
||||
* @param searchText Suchbegriff; {@code null} oder leer bedeutet kein Filter
|
||||
* @return neue Abfrage mit Textfilter
|
||||
*/
|
||||
public static HistoryQuery withSearchText(String searchText) {
|
||||
return new HistoryQuery(searchText, null, DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine Abfrage mit Status-Filter und Standard-Limit.
|
||||
*
|
||||
* @param statusFilter Enum-Name des gewünschten Status; {@code null} bedeutet kein Filter
|
||||
* @return neue Abfrage mit Status-Filter
|
||||
*/
|
||||
public static HistoryQuery withStatus(String statusFilter) {
|
||||
return new HistoryQuery(null, statusFilter, DEFAULT_LIMIT);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab.
|
||||
* <p>
|
||||
* Kapselt alle Datenbanklese-Operationen, die der Historien-Tab benötigt.
|
||||
* Die Implementierung liegt ausschließlich in {@code pdf-umbenenner-adapter-out}.
|
||||
* Die Application-Schicht kennt nur diesen Port-Vertrag – keine JDBC-Typen.
|
||||
*
|
||||
* <h2>Architektur</h2>
|
||||
* <p>
|
||||
* Dieser Port ist bewusst von {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository}
|
||||
* und {@link de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository}
|
||||
* getrennt, damit die bestehenden Repositories nicht mit GUI-spezifischen Methoden
|
||||
* aufgebläht werden.
|
||||
*/
|
||||
public interface HistoryQueryPort {
|
||||
|
||||
/**
|
||||
* Lädt eine gefilterte und sortierte Übersicht aller Dokumenteneinträge.
|
||||
* <p>
|
||||
* Sortierung: {@code updated_at DESC, fingerprint ASC} (stabiler Tie-Breaker).
|
||||
* Das in {@link HistoryQuery#limit()} angegebene Limit wird direkt als SQL-{@code LIMIT}
|
||||
* angewendet. Wenn das Limit 501 beträgt und 501 Zeilen zurückgegeben werden, gibt es
|
||||
* mehr als 500 Treffer.
|
||||
*
|
||||
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||
* technischen Datenbankfehlern
|
||||
*/
|
||||
List<DocumentHistoryRow> loadOverview(HistoryQuery query);
|
||||
|
||||
/**
|
||||
* Lädt den vollständigen Dokumenten-Stammsatz für den angegebenen Fingerprint.
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||
* technischen Datenbankfehlern
|
||||
*/
|
||||
Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint);
|
||||
|
||||
/**
|
||||
* Lädt alle historisierten Verarbeitungsversuche für den angegebenen Fingerprint,
|
||||
* aufsteigend sortiert nach {@code attempt_number}.
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste der Versuche; nie {@code null}; kann leer sein
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||
* technischen Datenbankfehlern
|
||||
*/
|
||||
List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs.
|
||||
* <p>
|
||||
* Enthält den {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort}
|
||||
* sowie die zugehörigen Datentypen
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery} und
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow}.
|
||||
* Diese Typen sind bewusst vom bestehenden {@code port.out}-Paket getrennt,
|
||||
* damit die allgemeinen Repository-Schnittstellen nicht mit GUI-spezifischen Methoden
|
||||
* belastet werden.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Use-Case-Implementierung für das vollständige Löschen eines Dokumenteintrags
|
||||
* aus dem Historien-Tab.
|
||||
* <p>
|
||||
* Löscht innerhalb einer Transaktion in der korrekten Reihenfolge, um den
|
||||
* Foreign-Key-Constraint zwischen {@code processing_attempt.fingerprint} und
|
||||
* {@code document_record.fingerprint} zu erfüllen (kein {@code ON DELETE CASCADE}):
|
||||
* <ol>
|
||||
* <li>Alle {@code processing_attempt}-Einträge zum Fingerprint</li>
|
||||
* <li>Den {@code document_record}-Stammsatz zum Fingerprint</li>
|
||||
* </ol>
|
||||
* Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert,
|
||||
* kehrt die Methode stillschweigend zurück.
|
||||
* <p>
|
||||
* <strong>Hinweis:</strong> Diese Aktion ist destruktiv und nicht rückgängig zu machen.
|
||||
* Die GUI muss vor dem Aufruf einen Bestätigungsdialog anzeigen.
|
||||
*/
|
||||
public class DefaultDeleteDocumentHistoryUseCase {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(DefaultDeleteDocumentHistoryUseCase.class);
|
||||
|
||||
private final UnitOfWorkPort unitOfWorkPort;
|
||||
|
||||
/**
|
||||
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
|
||||
*
|
||||
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
|
||||
*/
|
||||
public DefaultDeleteDocumentHistoryUseCase(UnitOfWorkPort unitOfWorkPort) {
|
||||
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||
* <p>
|
||||
* Die Löschung erfolgt in einer einzigen Transaktion. Versuche werden vor dem
|
||||
* Stammsatz gelöscht, damit der Foreign-Key-Constraint eingehalten wird.
|
||||
*
|
||||
* @param fingerprint der Dokumentbezeichner, dessen Daten vollständig gelöscht werden sollen;
|
||||
* darf nicht {@code null} sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
* @throws NullPointerException wenn {@code fingerprint} null ist
|
||||
*/
|
||||
public void deleteHistory(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
// Nutzung der bestehenden Transaktion mit korrekter Löschreihenfolge:
|
||||
// zuerst Versuche, dann Stammsatz (FK-Constraint)
|
||||
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentByFingerprint(fingerprint));
|
||||
|
||||
logger.info("Dokumenteintrag vollständig gelöscht für Fingerprint: {}", fingerprint.sha256Hex());
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Use-Case-Implementierung für das Laden der Detailansicht eines Dokuments im Historien-Tab.
|
||||
* <p>
|
||||
* Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche
|
||||
* für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt.
|
||||
* <p>
|
||||
* Wird kein Stammsatz gefunden (z. B. weil das Dokument zwischenzeitlich gelöscht wurde),
|
||||
* liefert {@link #loadDetails(DocumentFingerprint)} ein leeres {@link Optional}.
|
||||
*/
|
||||
public class DefaultHistoryDetailsUseCase {
|
||||
|
||||
private final HistoryQueryPort historyQueryPort;
|
||||
|
||||
/**
|
||||
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
|
||||
*
|
||||
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code historyQueryPort} null ist
|
||||
*/
|
||||
public DefaultHistoryDetailsUseCase(HistoryQueryPort historyQueryPort) {
|
||||
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||
*
|
||||
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||
* @return Optional mit den Detaildaten, oder leer wenn kein Stammsatz gefunden wurde
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
public Optional<HistoryDetailsResult> loadDetails(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
Optional<DocumentRecord> record = historyQueryPort.findRecordByFingerprint(fingerprint);
|
||||
if (record.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<ProcessingAttempt> attempts = historyQueryPort.findAttemptsByFingerprint(fingerprint);
|
||||
return Optional.of(new HistoryDetailsResult(record.get(), attempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis einer Historien-Detailabfrage.
|
||||
*
|
||||
* @param record Dokument-Stammsatz; nie {@code null}
|
||||
* @param attempts alle historisierten Verarbeitungsversuche aufsteigend nach Versuchsnummer;
|
||||
* nie {@code null}; kann leer sein
|
||||
*/
|
||||
public record HistoryDetailsResult(DocumentRecord record, List<ProcessingAttempt> attempts) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code record} oder {@code attempts} null ist
|
||||
*/
|
||||
public HistoryDetailsResult {
|
||||
Objects.requireNonNull(record, "record darf nicht null sein");
|
||||
Objects.requireNonNull(attempts, "attempts darf nicht null sein");
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
|
||||
/**
|
||||
* Use-Case-Implementierung für das Laden der Dokumentenliste im Historien-Tab.
|
||||
* <p>
|
||||
* Delegiert die Datenbankabfrage vollständig an {@link HistoryQueryPort} und
|
||||
* wertet das LIMIT-501-Ergebnis aus, um der GUI signalisieren zu können, ob
|
||||
* weitere Einträge vorhanden sind, die durch einen engeren Filter erreichbar wären.
|
||||
* <p>
|
||||
* <strong>LIMIT-501-Technik:</strong> Die Query wird mit {@code limit + 1 = 501}
|
||||
* ausgeführt (sofern das übergebene Limit 500 beträgt). Wenn die Datenbank 501
|
||||
* Zeilen zurückgibt, existieren mehr als 500 Treffer. Die zurückgegebene Liste
|
||||
* enthält dann exakt 500 Zeilen (das letzte Element wird verworfen) und
|
||||
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||
*/
|
||||
public class DefaultHistoryOverviewUseCase {
|
||||
|
||||
private static final int MAX_DISPLAY_COUNT = 500;
|
||||
|
||||
private final HistoryQueryPort historyQueryPort;
|
||||
|
||||
/**
|
||||
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
|
||||
*
|
||||
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code historyQueryPort} null ist
|
||||
*/
|
||||
public DefaultHistoryOverviewUseCase(HistoryQueryPort historyQueryPort) {
|
||||
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Dokumentenliste auf Basis der übergebenen Abfrageparameter.
|
||||
* <p>
|
||||
* Intern wird ein Limit von 501 verwendet, um erkennen zu können, ob mehr
|
||||
* als 500 Treffer vorhanden sind.
|
||||
*
|
||||
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
|
||||
* @return Ergebnisobjekt mit Trefferlist und {@code hasMore}-Flag; nie {@code null}
|
||||
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||
* bei technischen Datenbankfehlern
|
||||
*/
|
||||
public HistoryOverviewResult loadOverview(HistoryQuery query) {
|
||||
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||
|
||||
List<DocumentHistoryRow> rows = historyQueryPort.loadOverview(query);
|
||||
|
||||
if (rows.size() > MAX_DISPLAY_COUNT) {
|
||||
// 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden
|
||||
List<DocumentHistoryRow> truncated = List.copyOf(rows.subList(0, MAX_DISPLAY_COUNT));
|
||||
return new HistoryOverviewResult(truncated, true);
|
||||
}
|
||||
|
||||
return new HistoryOverviewResult(List.copyOf(rows), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis einer Historien-Übersichtsabfrage.
|
||||
*
|
||||
* @param rows Liste der Trefferzeilen; nie {@code null}; enthält maximal 500 Einträge
|
||||
* @param hasMore {@code true}, wenn mehr als 500 Treffer vorhanden sind und durch
|
||||
* einen engeren Filter eingegrenzt werden könnten
|
||||
*/
|
||||
public record HistoryOverviewResult(List<DocumentHistoryRow> rows, boolean hasMore) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code rows} null ist
|
||||
*/
|
||||
public HistoryOverviewResult {
|
||||
Objects.requireNonNull(rows, "rows darf nicht null sein");
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Use-Case-Implementierung für den feldgenauen Status-Reset aus dem Historien-Tab.
|
||||
* <p>
|
||||
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||
* ohne die Versuchshistorie zu löschen:
|
||||
* <ul>
|
||||
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||
* <li>{@code content_error_count} → {@code 0}</li>
|
||||
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||
* </ul>
|
||||
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
|
||||
* {@code last_target_path}, {@code last_target_file_name} sowie alle
|
||||
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
|
||||
* <p>
|
||||
* Nach dem Reset gilt das Dokument beim nächsten Verarbeitungslauf als verarbeitbar,
|
||||
* da {@code READY_FOR_AI} der einzige Trigger für die Verarbeitungslogik ist.
|
||||
* <p>
|
||||
* <strong>Abgrenzung:</strong> Dieser Use-Case unterscheidet sich von
|
||||
* {@link DefaultResetDocumentStatusUseCase}, der alle Persistenzdaten (Stammsatz und
|
||||
* Versuchshistorie) vollständig löscht und das Dokument so behandelt, als wäre es
|
||||
* noch nie verarbeitet worden.
|
||||
*/
|
||||
public class DefaultHistoryResetDocumentStatusUseCase {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(DefaultHistoryResetDocumentStatusUseCase.class);
|
||||
|
||||
private final UnitOfWorkPort unitOfWorkPort;
|
||||
|
||||
/**
|
||||
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
|
||||
*
|
||||
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
|
||||
*/
|
||||
public DefaultHistoryResetDocumentStatusUseCase(UnitOfWorkPort unitOfWorkPort) {
|
||||
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt den feldgenauen Status-Reset für den angegebenen Fingerprint durch.
|
||||
* <p>
|
||||
* Die Operation ist atomar: entweder werden alle vier Felder aktualisiert,
|
||||
* oder keine Änderung findet statt (Rollback).
|
||||
*
|
||||
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||
* darf nicht {@code null} sein
|
||||
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||
* @throws NullPointerException wenn {@code fingerprint} null ist
|
||||
*/
|
||||
public void resetStatus(DocumentFingerprint fingerprint) {
|
||||
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||
|
||||
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentStatusForRetry(fingerprint));
|
||||
|
||||
logger.info("Feldgenauer Status-Reset durchgeführt für Fingerprint: {}", fingerprint.sha256Hex());
|
||||
}
|
||||
}
|
||||
+5
@@ -1416,6 +1416,11 @@ class DocumentProcessingCoordinatorTest {
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
};
|
||||
|
||||
operations.accept(mockOps);
|
||||
|
||||
+10
@@ -1396,6 +1396,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
// No-op
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1604,6 +1609,11 @@ class BatchRunProcessingUseCaseTest {
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Tests für {@link DefaultDeleteDocumentHistoryUseCase}.
|
||||
* <p>
|
||||
* Prüft, dass ausschließlich {@code resetDocumentByFingerprint} aufgerufen wird
|
||||
* (vollständige Löschung inklusive Versuchen, FK-sicher), Null-Guards greifen
|
||||
* und Port-Fehler propagiert werden.
|
||||
*/
|
||||
class DefaultDeleteDocumentHistoryUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FP =
|
||||
new DocumentFingerprint("b".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Null-Guards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_nullPort_throwsNPE() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultDeleteDocumentHistoryUseCase(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteHistory_nullFingerprint_throwsNPE() {
|
||||
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||
new DefaultDeleteDocumentHistoryUseCase(noOpPort());
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> useCase.deleteHistory(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path: vollständige Löschung
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void deleteHistory_callsResetDocumentByFingerprint() {
|
||||
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||
|
||||
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||
useCase.deleteHistory(FP);
|
||||
|
||||
assertThat(ops.resetByFingerprintFingerprints)
|
||||
.containsExactly(FP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteHistory_doesNotCallResetDocumentStatusForRetry() {
|
||||
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||
|
||||
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||
useCase.deleteHistory(FP);
|
||||
|
||||
assertThat(ops.resetStatusForRetryFingerprints).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Port-Fehler wird propagiert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void deleteHistory_portThrows_exceptionPropagated() {
|
||||
UnitOfWorkPort failingPort = operations ->
|
||||
operations.accept(new 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) {
|
||||
throw new DocumentPersistenceException("Simulated DB error");
|
||||
}
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||
});
|
||||
|
||||
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||
new DefaultDeleteDocumentHistoryUseCase(failingPort);
|
||||
|
||||
assertThatThrownBy(() -> useCase.deleteHistory(FP))
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("Simulated DB error");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static UnitOfWorkPort noOpPort() {
|
||||
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet {@code resetDocumentByFingerprint}- und {@code resetDocumentStatusForRetry}-Aufrufe auf.
|
||||
*/
|
||||
private static class RecordingTransactionOperations
|
||||
implements UnitOfWorkPort.TransactionOperations {
|
||||
|
||||
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
resetByFingerprintFingerprints.add(fingerprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
resetStatusForRetryFingerprints.add(fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.FailureCounters;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||
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 DefaultHistoryDetailsUseCase}.
|
||||
* <p>
|
||||
* Prüft den Happy-Path (Stammsatz vorhanden), das leere-Optional-Verhalten
|
||||
* (kein Stammsatz), Null-Guards und Port-Fehler-Propagation.
|
||||
*/
|
||||
class DefaultHistoryDetailsUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FP =
|
||||
new DocumentFingerprint("a".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Null-Guards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_nullPort_throwsNPE() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultHistoryDetailsUseCase(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadDetails_nullFingerprint_throwsNPE() {
|
||||
DefaultHistoryDetailsUseCase useCase =
|
||||
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> useCase.loadDetails(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Kein Stammsatz vorhanden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadDetails_noRecord_returnsEmpty() {
|
||||
DefaultHistoryDetailsUseCase useCase =
|
||||
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||
|
||||
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path: Stammsatz vorhanden, Versuche vorhanden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadDetails_recordExists_returnsResultWithRecordAndAttempts() {
|
||||
DocumentRecord record = buildRecord(FP);
|
||||
ProcessingAttempt attempt = buildAttempt(FP);
|
||||
|
||||
HistoryQueryPort port = new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
return Optional.of(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return List.of(attempt);
|
||||
}
|
||||
};
|
||||
|
||||
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().record()).isSameAs(record);
|
||||
assertThat(result.get().attempts()).containsExactly(attempt);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stammsatz vorhanden, keine Versuche
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadDetails_recordExistsNoAttempts_returnsResultWithEmptyAttempts() {
|
||||
DocumentRecord record = buildRecord(FP);
|
||||
|
||||
HistoryQueryPort port = new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
return Optional.of(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
|
||||
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().attempts()).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Port-Fehler wird propagiert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadDetails_portThrowsOnRecord_exceptionPropagated() {
|
||||
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
throw new DocumentPersistenceException("Simulated DB error");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(failingPort);
|
||||
|
||||
assertThatThrownBy(() -> useCase.loadDetails(FP))
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("Simulated DB error");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static HistoryQueryPort emptyPort() {
|
||||
return new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DocumentRecord buildRecord(DocumentFingerprint fp) {
|
||||
return new DocumentRecord(
|
||||
fp,
|
||||
new SourceDocumentLocator("/source"),
|
||||
"source.pdf",
|
||||
ProcessingStatus.SUCCESS,
|
||||
new FailureCounters(0, 0),
|
||||
null,
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
"/target",
|
||||
"2024-01-01 - Dokument.pdf");
|
||||
}
|
||||
|
||||
private static ProcessingAttempt buildAttempt(DocumentFingerprint fp) {
|
||||
return ProcessingAttempt.withoutAiFields(
|
||||
fp,
|
||||
new de.gecheckt.pdf.umbenenner.domain.model.RunId(
|
||||
java.util.UUID.randomUUID().toString()),
|
||||
1,
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
ProcessingStatus.SUCCESS,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||
|
||||
/**
|
||||
* Tests für {@link DefaultHistoryOverviewUseCase}.
|
||||
* <p>
|
||||
* Prüft den Happy-Path, das LIMIT-501-Verhalten und Null-Guards.
|
||||
*/
|
||||
class DefaultHistoryOverviewUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FP =
|
||||
new DocumentFingerprint("a".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Null-Guards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_nullPort_throwsNPE() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultHistoryOverviewUseCase(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadOverview_nullQuery_throwsNPE() {
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> useCase.loadOverview(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path: leer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadOverview_emptyDatabase_returnsEmptyResultWithoutMore() {
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||
|
||||
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||
|
||||
assertThat(result.rows()).isEmpty();
|
||||
assertThat(result.hasMore()).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path: weniger als 500 Treffer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadOverview_fewerThan500Results_returnsAllRowsWithoutMore() {
|
||||
List<DocumentHistoryRow> rows = buildRows(10);
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||
|
||||
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||
|
||||
assertThat(result.rows()).hasSize(10);
|
||||
assertThat(result.hasMore()).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LIMIT-501-Technik
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadOverview_exactly500Results_returnsAllWithoutMore() {
|
||||
List<DocumentHistoryRow> rows = buildRows(500);
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||
|
||||
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||
|
||||
assertThat(result.rows()).hasSize(500);
|
||||
assertThat(result.hasMore()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadOverview_moreThan500Results_returns500RowsWithHasMore() {
|
||||
List<DocumentHistoryRow> rows = buildRows(501);
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||
|
||||
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||
|
||||
assertThat(result.rows()).hasSize(500);
|
||||
assertThat(result.hasMore()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadOverview_resultListIsImmutable() {
|
||||
List<DocumentHistoryRow> rows = buildRows(3);
|
||||
DefaultHistoryOverviewUseCase useCase =
|
||||
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||
|
||||
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||
|
||||
assertThatThrownBy(() -> result.rows().add(buildRow("0".repeat(64))))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Port-Fehler wird propagiert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void loadOverview_portThrows_exceptionPropagated() {
|
||||
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
throw new DocumentPersistenceException("Simulated DB error");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(failingPort);
|
||||
|
||||
assertThatThrownBy(() -> useCase.loadOverview(HistoryQuery.unfiltered()))
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("Simulated DB error");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static HistoryQueryPort emptyPort() {
|
||||
return fixedPort(Collections.emptyList());
|
||||
}
|
||||
|
||||
private static HistoryQueryPort fixedPort(List<DocumentHistoryRow> rows) {
|
||||
return new HistoryQueryPort() {
|
||||
@Override
|
||||
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||
return new ArrayList<>(rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<DocumentHistoryRow> buildRows(int count) {
|
||||
List<DocumentHistoryRow> result = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
String hex = String.format("%064x", i);
|
||||
result.add(buildRow(hex));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DocumentHistoryRow buildRow(String fpHex) {
|
||||
return new DocumentHistoryRow(
|
||||
new DocumentFingerprint(fpHex),
|
||||
ProcessingStatus.SUCCESS,
|
||||
"source.pdf",
|
||||
"2024-01-01 - Dokument.pdf",
|
||||
"/source",
|
||||
Instant.now(),
|
||||
1L);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.ProcessingAttempt;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||
|
||||
/**
|
||||
* Tests für {@link DefaultHistoryResetDocumentStatusUseCase}.
|
||||
* <p>
|
||||
* Prüft, dass ausschließlich {@code resetDocumentStatusForRetry} aufgerufen wird
|
||||
* (nicht {@code resetDocumentByFingerprint}), Null-Guards greifen und
|
||||
* Port-Fehler propagiert werden.
|
||||
*/
|
||||
class DefaultHistoryResetDocumentStatusUseCaseTest {
|
||||
|
||||
private static final DocumentFingerprint FP =
|
||||
new DocumentFingerprint("a".repeat(64));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Null-Guards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void constructor_nullPort_throwsNPE() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new DefaultHistoryResetDocumentStatusUseCase(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetStatus_nullFingerprint_throwsNPE() {
|
||||
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||
new DefaultHistoryResetDocumentStatusUseCase(noOpPort());
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> useCase.resetStatus(null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path: feldgenauer Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void resetStatus_callsResetDocumentStatusForRetry() {
|
||||
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||
|
||||
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||
useCase.resetStatus(FP);
|
||||
|
||||
assertThat(ops.resetStatusForRetryFingerprints)
|
||||
.containsExactly(FP);
|
||||
assertThat(ops.resetByFingerprintFingerprints)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetStatus_doesNotCallResetDocumentByFingerprint() {
|
||||
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||
|
||||
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||
useCase.resetStatus(FP);
|
||||
|
||||
assertThat(ops.resetByFingerprintFingerprints).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Port-Fehler wird propagiert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void resetStatus_portThrows_exceptionPropagated() {
|
||||
UnitOfWorkPort failingPort = operations ->
|
||||
operations.accept(new 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) { }
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
throw new DocumentPersistenceException("Simulated DB error");
|
||||
}
|
||||
});
|
||||
|
||||
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||
new DefaultHistoryResetDocumentStatusUseCase(failingPort);
|
||||
|
||||
assertThatThrownBy(() -> useCase.resetStatus(FP))
|
||||
.isInstanceOf(DocumentPersistenceException.class)
|
||||
.hasMessageContaining("Simulated DB error");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static UnitOfWorkPort noOpPort() {
|
||||
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet {@code resetDocumentStatusForRetry}- und {@code resetDocumentByFingerprint}-Aufrufe auf.
|
||||
*/
|
||||
private static class RecordingTransactionOperations
|
||||
implements UnitOfWorkPort.TransactionOperations {
|
||||
|
||||
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||
|
||||
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||
|
||||
@Override
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
resetByFingerprintFingerprints.add(fingerprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
resetStatusForRetryFingerprints.add(fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -549,6 +549,7 @@ class DefaultManualFileCopyUseCaseTest {
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
|
||||
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
||||
@@ -562,5 +563,6 @@ class DefaultManualFileCopyUseCaseTest {
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -620,6 +620,7 @@ class DefaultManualFileRenameUseCaseTest {
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
|
||||
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
|
||||
@@ -634,5 +635,6 @@ class DefaultManualFileRenameUseCaseTest {
|
||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -216,5 +216,10 @@ class DefaultResetDocumentStatusUseCaseTest {
|
||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||
recorded.add(fingerprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||
// No-op in tests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user